1use nostr_sdk::prelude::{FinalizeEvent, FinalizeUnsignedEvent};
15use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
16use nostr_sdk::prelude::ToBech32;
17use rusqlite::{params, OptionalExtension};
18
19use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
20
21fn now_secs() -> i64 {
22 std::time::SystemTime::now()
23 .duration_since(std::time::UNIX_EPOCH)
24 .map(|d| d.as_secs() as i64)
25 .unwrap_or(0)
26}
27
28fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> {
29 bytes
30 .try_into()
31 .map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len()))
32}
33
34fn enc_key(k: &[u8; 32]) -> Result<Vec<u8>, String> { crate::crypto::maybe_encrypt_blob(k) }
36fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) }
37fn enc_txt(s: &str) -> Result<String, String> { crate::crypto::maybe_encrypt_text(s) }
38fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) }
39fn enc_txt_opt(s: &Option<String>) -> Result<Option<String>, String> {
41 s.as_deref().map(enc_txt).transpose()
42}
43
44pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> {
48 crate::simd::hex::hex_to_bytes_32_checked(hex)
49 .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len()))
50}
51
52pub fn save_community(community: &Community) -> Result<(), String> {
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(|i| serde_json::to_string(i))
64 .transpose()
65 .map_err(|e| e.to_string())?;
66 let banner_json = community
67 .banner
68 .as_ref()
69 .map(|b| serde_json::to_string(b))
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
150pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
157 let conn = super::get_write_connection_guard_static()?;
158 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
159}
160
161fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
165 conn: &C,
166 community_id: &str,
167 scope_id: &str,
168 epoch: u64,
169 key: &[u8; 32],
170) -> Result<(), String> {
171 let enc = enc_key(key)?;
172 conn.execute(
173 "INSERT OR REPLACE INTO community_epoch_keys
174 (community_id, scope_id, epoch, key, created_at)
175 VALUES (?1, ?2, ?3, ?4, ?5)",
176 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
178 )
179 .map_err(|e| format!("store epoch key: {e}"))?;
180 Ok(())
181}
182
183pub fn advance_channel_epoch(
190 community_id: &str,
191 channel_id: &str,
192 new_epoch: u64,
193 new_key: &[u8; 32],
194) -> Result<bool, String> {
195 let conn = super::get_write_connection_guard_static()?;
196 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
197 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
199 let cur: Option<i64> = tx
201 .query_row(
202 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
203 params![community_id, channel_id],
204 |r| r.get(0),
205 )
206 .optional()
207 .map_err(|e| format!("read channel head: {e}"))?;
208 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
209 if advanced {
210 let enc = enc_key(new_key)?;
211 tx.execute(
212 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
213 WHERE community_id = ?3 AND channel_id = ?4",
214 params![new_epoch as i64, &enc[..], community_id, channel_id],
215 )
216 .map_err(|e| format!("advance channel head: {e}"))?;
217 }
218 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
219 Ok(advanced)
220}
221
222pub fn get_server_root_epoch(community_id: &str) -> Result<Option<u64>, String> {
231 let conn = super::get_db_connection_guard_static()?;
232 conn.query_row(
233 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
234 params![community_id],
235 |r| r.get::<_, i64>(0),
236 )
237 .optional()
238 .map(|v| v.map(|e| e as u64))
239 .map_err(|e| format!("get server root epoch: {e}"))
240}
241
242pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
243 let conn = super::get_write_connection_guard_static()?;
244 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
245 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
248 let cur: Option<i64> = tx
249 .query_row(
250 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
251 params![community_id],
252 |r| r.get(0),
253 )
254 .optional()
255 .map_err(|e| format!("read server-root epoch: {e}"))?;
256 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
257 if advanced {
258 let enc = enc_key(new_root)?;
259 tx.execute(
260 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
261 params![new_epoch as i64, &enc[..], community_id],
262 )
263 .map_err(|e| format!("advance server-root head: {e}"))?;
264 }
265 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
266 Ok(advanced)
267}
268
269pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
276 let conn = super::get_write_connection_guard_static()?;
277 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
278 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
279 let enc = enc_key(new_root)?;
280 let switched = tx
281 .execute(
282 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
283 params![&enc[..], community_id, epoch as i64],
284 )
285 .map_err(|e| format!("converge server-root head: {e}"))?
286 > 0;
287 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
288 Ok(switched)
289}
290
291pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
296 let conn = super::get_write_connection_guard_static()?;
297 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
298 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
299 let enc = enc_key(new_key)?;
300 let switched = tx
301 .execute(
302 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
303 params![&enc[..], community_id, channel_id, epoch as i64],
304 )
305 .map_err(|e| format!("converge channel head: {e}"))?
306 > 0;
307 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
308 Ok(switched)
309}
310
311pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
315 let conn = super::get_db_connection_guard_static()?;
316 let mut stmt = conn
317 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
318 .map_err(|e| e.to_string())?;
319 let rows = stmt
320 .query_map(params![community_id, scope_id], |r| {
321 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
322 })
323 .map_err(|e| e.to_string())?;
324 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
325 for row in rows {
326 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
327 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
328 }
329 out.sort_by_key(|(e, _)| e.0);
330 Ok(out)
331}
332
333pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
336 let conn = super::get_db_connection_guard_static()?;
337 let blob: Option<Vec<u8>> = conn
338 .query_row(
339 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
340 params![community_id, scope_id, epoch as i64],
341 |r| r.get(0),
342 )
343 .optional()
344 .map_err(|e| format!("held epoch key: {e}"))?;
345 blob.map(|b| dec_key(&b)).transpose()
346}
347
348pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
352 let conn = super::get_db_connection_guard_static().ok()?;
353 conn.query_row(
354 "SELECT created_at FROM communities WHERE community_id = ?1",
355 params![id.to_hex()],
356 |r| r.get::<_, i64>(0),
357 )
358 .optional()
359 .ok()
360 .flatten()
361 .map(|secs| (secs.max(0) as u64) * 1000)
362}
363
364pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
366 let conn = super::get_db_connection_guard_static()?;
367 let id_hex = id.to_hex();
368
369 let row = conn
370 .query_row(
371 "SELECT server_root_key, name, relays,
372 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
373 FROM communities WHERE community_id = ?1",
374 params![id_hex],
375 |r| {
376 Ok((
377 r.get::<_, Vec<u8>>(0)?,
378 r.get::<_, String>(1)?,
379 r.get::<_, String>(2)?,
380 r.get::<_, Option<String>>(3)?,
381 r.get::<_, Option<String>>(4)?,
382 r.get::<_, Option<String>>(5)?,
383 r.get::<_, String>(6)?,
384 r.get::<_, Option<String>>(7)?,
385 r.get::<_, i64>(8)?,
386 r.get::<_, i64>(9)?,
387 ))
388 },
389 )
390 .optional()
391 .map_err(|e| format!("load community: {e}"))?;
392
393 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
394 match row {
395 Some(t) => t,
396 None => return Ok(None),
397 };
398 let dissolved = dissolved_int != 0;
399
400 let name = dec_txt(&name);
402 let relays_json = dec_txt(&relays_json);
403 let description = description.map(|s| dec_txt(&s));
404 let icon_json = icon_json.map(|s| dec_txt(&s));
405 let banner_json = banner_json.map(|s| dec_txt(&s));
406 let banlist_json = dec_txt(&banlist_json);
407 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
408
409 let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
413 .unwrap_or_default()
414 .iter()
415 .filter_map(|h| PublicKey::from_hex(h).ok())
416 .collect();
417
418 let icon = icon_json
419 .map(|j| serde_json::from_str(&j))
420 .transpose()
421 .map_err(|e| format!("icon json: {e}"))?;
422 let banner = banner_json
423 .map(|j| serde_json::from_str(&j))
424 .transpose()
425 .map_err(|e| format!("banner json: {e}"))?;
426
427 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
428 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
429
430 let mut protected: Vec<PublicKey> = Vec::new();
437 if let Some(owner) = owner_attestation
438 .as_ref()
439 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
440 {
441 protected.push(owner);
442 }
443 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
444
445 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
448 let mut stmt = conn
449 .prepare(
450 "SELECT channel_id, channel_key, epoch, name
451 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
452 )
453 .map_err(|e| e.to_string())?;
454 let rows = stmt
455 .query_map(params![id_hex], |r| {
456 Ok((
457 r.get::<_, String>(0)?,
458 r.get::<_, Vec<u8>>(1)?,
459 r.get::<_, i64>(2)?,
460 r.get::<_, String>(3)?,
461 ))
462 })
463 .map_err(|e| e.to_string())?;
464 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
465 };
466
467 let roster = get_community_roles(&id_hex).unwrap_or_default();
470
471 let mut channels = Vec::new();
472 for (cid_hex, key_blob, epoch, cname) in raw_channels {
473 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
477 let mut ek_stmt = conn
478 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
479 .map_err(|e| e.to_string())?;
480 let rows = ek_stmt
481 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
482 .map_err(|e| e.to_string())?;
483 let mut out = Vec::new();
484 for row in rows {
485 let (e, blob) = row.map_err(|e| e.to_string())?;
486 if let Ok(k) = dec_key(&blob) {
487 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
488 }
489 }
490 out
491 };
492 channels.push(Channel {
493 id: ChannelId(hex_id_to_32(&cid_hex)?),
494 key: ChannelKey(dec_key(&key_blob)?),
495 epoch: Epoch(epoch as u64),
498 name: dec_txt(&cname),
499 banned: banned.clone(),
500 protected: protected.clone(),
501 roster: roster.clone(),
502 epoch_keys,
503 dissolved,
504 });
505 }
506
507 Ok(Some(Community {
508 id: *id,
509 server_root_key,
510 server_root_epoch: Epoch(server_root_epoch as u64),
512 name,
513 description,
514 icon,
515 banner,
516 relays,
517 channels,
518 owner_attestation,
519 dissolved,
520 }))
521}
522
523pub fn store_message_key(
526 message_id: &str,
527 outer_event_id: &str,
528 ephemeral: &Keys,
529 relays: &[String],
530) -> Result<(), String> {
531 let conn = super::get_write_connection_guard_static()?;
532 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
533 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
534 let enc_secret = enc_key(&sk_bytes)?;
535 let enc_relays = enc_txt(&relays_json)?;
536 conn.execute(
537 "INSERT OR REPLACE INTO community_message_keys
538 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
539 VALUES (?1, ?2, ?3, ?4, ?5)",
540 params![
541 outer_event_id,
542 message_id,
543 &enc_secret[..],
544 enc_relays,
545 now_secs(),
546 ],
547 )
548 .map_err(|e| format!("store message key: {e}"))?;
549 Ok(())
550}
551
552pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
558 let conn = super::get_db_connection_guard_static()?;
559 let row = conn
560 .query_row(
561 "SELECT ephemeral_secret, outer_event_id, relays
562 FROM community_message_keys WHERE message_id = ?1",
563 params![message_id],
564 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
565 )
566 .optional()
567 .map_err(|e| format!("get message key: {e}"))?;
568 let (secret_blob, outer_event_id, relays_json) = match row {
569 Some(t) => t,
570 None => return Ok(None),
571 };
572 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
573 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
574 Ok(Some((Keys::new(secret), outer_event_id, relays)))
575}
576
577pub fn delete_message_key(message_id: &str) -> Result<(), String> {
579 let conn = super::get_write_connection_guard_static()?;
580 conn.execute(
581 "DELETE FROM community_message_keys WHERE message_id = ?1",
582 params![message_id],
583 )
584 .map_err(|e| format!("remove message key: {e}"))?;
585 Ok(())
586}
587
588pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
591 let r = get_message_key(message_id)?;
592 if r.is_some() {
593 delete_message_key(message_id)?;
594 }
595 Ok(r)
596}
597
598static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
610 std::sync::RwLock<std::collections::HashMap<String, String>>,
611> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
612
613pub fn clear_channel_community_cache() {
615 CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
616}
617
618fn forget_community_channels(community_id: &str) {
621 CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
622}
623
624pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
625 if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
626 return Ok(Some(cid.clone()));
627 }
628 let conn = super::get_db_connection_guard_static()?;
629 let cid: Option<String> = conn
630 .query_row(
631 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
632 params![channel_id],
633 |r| r.get::<_, String>(0),
634 )
635 .optional()
636 .map_err(|e| format!("community_id_for_channel: {e}"))?;
637 if let Some(ref c) = cid {
638 CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
639 }
640 Ok(cid)
641}
642
643pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
646 let conn = super::get_db_connection_guard_static()?;
647 let found: Option<i64> = conn
648 .query_row(
649 "SELECT 1 FROM communities WHERE community_id = ?1",
650 params![id.to_hex()],
651 |r| r.get(0),
652 )
653 .optional()
654 .map_err(|e| format!("community_exists: {e}"))?;
655 Ok(found.is_some())
656}
657
658#[derive(Debug, Clone, serde::Serialize)]
660pub struct PendingCommunityInvite {
661 pub community_id: String,
662 pub bundle_json: String,
663 pub inviter_npub: String,
664 pub received_at: i64,
665 pub expires_at: i64,
667}
668
669pub fn save_pending_invite(
674 community_id: &str,
675 bundle_json: &str,
676 inviter_npub: &str,
677 expires_at: i64,
678) -> Result<bool, String> {
679 const MAX_PENDING_INVITES: usize = 100;
683
684 let conn = super::get_write_connection_guard_static()?;
685 let enc_bundle = enc_txt(bundle_json)?;
686 let enc_inviter = enc_txt(inviter_npub)?;
687 let changed = conn
692 .execute(
693 "INSERT OR IGNORE INTO pending_community_invites
694 (community_id, bundle_json, inviter_npub, received_at, expires_at)
695 VALUES (?1, ?2, ?3, ?4, ?5)",
696 params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
697 )
698 .map_err(|e| format!("save pending invite: {e}"))?;
699 if changed > 0 {
702 let _ = conn.execute(
703 "DELETE FROM pending_community_invites
704 WHERE community_id IN (
705 SELECT community_id FROM pending_community_invites
706 ORDER BY received_at DESC, community_id DESC
707 LIMIT -1 OFFSET ?1
708 )",
709 params![MAX_PENDING_INVITES],
710 );
711 }
712 Ok(changed > 0)
713}
714
715pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
721 let conn = super::get_write_connection_guard_static()?;
722 let n = conn
723 .execute(
724 "DELETE FROM pending_community_invites
725 WHERE community_id IN (SELECT community_id FROM communities)",
726 [],
727 )
728 .map_err(|e| format!("purge held pending invites: {e}"))?;
729 Ok(n)
730}
731
732pub fn purge_expired_pending_invites() -> Result<usize, String> {
740 let conn = super::get_write_connection_guard_static()?;
741 let now = now_secs();
742 let n = conn
743 .execute(
744 "DELETE FROM pending_community_invites
745 WHERE (expires_at != 0 AND expires_at <= ?1)
746 OR received_at <= ?2",
747 params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
748 )
749 .map_err(|e| format!("purge expired pending invites: {e}"))?;
750 Ok(n)
751}
752
753pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
755 let conn = super::get_db_connection_guard_static()?;
756 let mut stmt = conn
757 .prepare(
758 "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
759 FROM pending_community_invites
760 WHERE (expires_at = 0 OR expires_at > ?1)
761 AND received_at > ?2
762 ORDER BY received_at DESC",
763 )
764 .map_err(|e| e.to_string())?;
765 let now = now_secs();
766 let rows = stmt
767 .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
768 Ok(PendingCommunityInvite {
769 community_id: r.get(0)?,
770 bundle_json: dec_txt(&r.get::<_, String>(1)?),
771 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
772 received_at: r.get(3)?,
773 expires_at: r.get(4)?,
774 })
775 })
776 .map_err(|e| e.to_string())?;
777 let mut out = Vec::new();
778 for row in rows {
779 out.push(row.map_err(|e| e.to_string())?);
780 }
781 Ok(out)
782}
783
784pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
788 let conn = super::get_db_connection_guard_static()?;
789 let raw: Option<String> = conn
790 .query_row(
791 "SELECT bundle_json FROM pending_community_invites
792 WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
793 params![community_id, now_secs()],
794 |r| r.get::<_, String>(0),
795 )
796 .optional()
797 .map_err(|e| format!("get pending invite: {e}"))?;
798 Ok(raw.map(|s| dec_txt(&s)))
799}
800
801pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
803 let conn = super::get_write_connection_guard_static()?;
804 conn.execute(
805 "DELETE FROM pending_community_invites WHERE community_id = ?1",
806 params![community_id],
807 )
808 .map_err(|e| format!("delete pending invite: {e}"))?;
809 Ok(())
810}
811
812pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
817 let conn = super::get_db_connection_guard_static()?;
818 conn.query_row(
819 "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
820 params![community_id],
821 |r| r.get(0),
822 )
823 .optional()
824 .map_err(|e| format!("pending_invite_received_at: {e}"))
825}
826
827pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
828 let conn = super::get_db_connection_guard_static()?;
829 let found: Option<i64> = conn
830 .query_row(
831 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
832 params![community_id],
833 |r| r.get(0),
834 )
835 .optional()
836 .map_err(|e| format!("pending_invite_exists: {e}"))?;
837 Ok(found.is_some())
838}
839
840#[derive(Debug, Clone, serde::Serialize)]
842pub struct PublicInviteRecord {
843 pub token: String,
845 pub community_id: String,
846 pub url: String,
847 pub expires_at: Option<i64>,
848 pub created_at: i64,
849 pub label: Option<String>,
851 #[serde(default)]
853 pub join_count: u64,
854}
855
856pub fn save_public_invite(
858 token: &str,
859 community_id: &str,
860 url: &str,
861 expires_at: Option<i64>,
862 label: Option<&str>,
863) -> Result<(), String> {
864 let conn = super::get_write_connection_guard_static()?;
865 let enc_token = enc_txt(token)?;
868 let enc_url = enc_txt(url)?;
869 let enc_label = label.map(enc_txt).transpose()?;
871 conn.execute(
872 "INSERT OR REPLACE INTO community_public_invites
873 (token, community_id, url, expires_at, created_at, label)
874 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
875 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
876 )
877 .map_err(|e| format!("save public invite: {e}"))?;
878 Ok(())
879}
880
881pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
883 let conn = super::get_db_connection_guard_static()?;
884 let mut stmt = conn
885 .prepare(
886 "SELECT token, community_id, url, expires_at, created_at, label
887 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
888 )
889 .map_err(|e| e.to_string())?;
890 let rows = stmt
891 .query_map(params![community_id], |r| {
892 Ok(PublicInviteRecord {
893 token: dec_txt(&r.get::<_, String>(0)?),
894 community_id: r.get(1)?,
895 url: dec_txt(&r.get::<_, String>(2)?),
896 expires_at: r.get(3)?,
897 created_at: r.get(4)?,
898 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
899 join_count: 0,
900 })
901 })
902 .map_err(|e| e.to_string())?;
903 let mut out = Vec::new();
904 for row in rows {
905 out.push(row.map_err(|e| e.to_string())?);
906 }
907 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
909 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
910 for rec in &mut out {
911 if let Some(l) = rec.label.as_deref() {
912 rec.join_count = counts.get(l).copied().unwrap_or(0);
913 }
914 }
915 }
916 }
917 Ok(out)
918}
919
920pub fn delete_public_invite(token: &str) -> Result<(), String> {
922 let conn = super::get_write_connection_guard_static()?;
923 let rows: Vec<(i64, String)> = {
926 let mut stmt = conn
927 .prepare("SELECT rowid, token FROM community_public_invites")
928 .map_err(|e| e.to_string())?;
929 let mapped = stmt
930 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
931 .map_err(|e| e.to_string())?;
932 mapped.filter_map(|r| r.ok()).collect()
933 };
934 for (rowid, stored) in rows {
935 if dec_txt(&stored) == token {
936 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
937 .map_err(|e| format!("delete public invite: {e}"))?;
938 }
939 }
940 Ok(())
941}
942
943pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
945 let conn = super::get_db_connection_guard_static()?;
946 let mut stmt = conn
947 .prepare(
948 "SELECT token, community_id, url, expires_at, created_at, label
949 FROM community_public_invites ORDER BY created_at DESC",
950 )
951 .map_err(|e| e.to_string())?;
952 let rows = stmt
953 .query_map([], |r| {
954 Ok(PublicInviteRecord {
955 token: dec_txt(&r.get::<_, String>(0)?),
956 community_id: r.get(1)?,
957 url: dec_txt(&r.get::<_, String>(2)?),
958 expires_at: r.get(3)?,
959 created_at: r.get(4)?,
960 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
961 join_count: 0,
962 })
963 })
964 .map_err(|e| e.to_string())?;
965 let mut out = Vec::new();
966 for row in rows {
967 out.push(row.map_err(|e| e.to_string())?);
968 }
969 Ok(out)
970}
971
972pub fn upsert_public_invite(
977 token: &str,
978 community_id: &str,
979 url: &str,
980 expires_at: Option<i64>,
981 created_at: i64,
982 label: Option<&str>,
983) -> Result<bool, String> {
984 let conn = super::get_write_connection_guard_static()?;
985 let already = {
986 let mut stmt = conn
987 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
988 .map_err(|e| e.to_string())?;
989 let stored: Vec<String> = stmt
990 .query_map(params![community_id], |r| r.get::<_, String>(0))
991 .map_err(|e| e.to_string())?
992 .filter_map(|r| r.ok())
993 .collect();
994 stored.iter().any(|s| dec_txt(s) == token)
995 };
996 if already {
997 return Ok(false);
998 }
999 let enc_token = enc_txt(token)?;
1000 let enc_url = enc_txt(url)?;
1001 let enc_label = label.map(enc_txt).transpose()?;
1002 conn.execute(
1003 "INSERT INTO community_public_invites
1004 (token, community_id, url, expires_at, created_at, label)
1005 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1006 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1007 )
1008 .map_err(|e| format!("upsert public invite: {e}"))?;
1009 Ok(true)
1010}
1011
1012pub fn delete_community(community_id: &str) -> Result<(), String> {
1017 delete_community_inner(community_id, false)
1018}
1019
1020pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1026 delete_community_inner(community_id, true)
1027}
1028
1029fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1030 let conn = super::get_write_connection_guard_static()?;
1031 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1034 for sql in [
1035 Some("DELETE FROM communities WHERE community_id = ?1"),
1036 Some("DELETE FROM community_channels WHERE community_id = ?1"),
1037 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1041 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1042 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1043 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1044 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1047 ]
1048 .into_iter()
1049 .flatten()
1050 {
1051 tx.execute(sql, params![community_id])
1052 .map_err(|e| format!("delete community: {e}"))?;
1053 }
1054 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1055 BANLIST_CACHE.write().unwrap().remove(community_id);
1056 forget_community_channels(community_id);
1057 Ok(())
1062}
1063
1064pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1071 community_member_activity_capped(community_id, true)
1072}
1073
1074pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1080 const COMMUNITY_MEMBER_CAP: usize = 500;
1083 use std::collections::HashMap;
1084
1085 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1086 Some(c) => c,
1087 None => return Ok(Vec::new()),
1088 };
1089 let owner_b32: Option<String> = community
1094 .owner_attestation
1095 .as_deref()
1096 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1097 .and_then(|pk| pk.to_bech32().ok());
1098
1099 let mut chat_ints: Vec<i64> = Vec::new();
1101 for ch in &community.channels {
1102 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1103 chat_ints.push(cid);
1104 }
1105 }
1106
1107 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1110
1111 let mut active: HashMap<String, u64> = HashMap::new();
1116 let mut left: HashMap<String, u64> = HashMap::new();
1117 if !chat_ints.is_empty() {
1120 let conn = super::get_db_connection_guard_static()?;
1121 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1122
1123 {
1124 let sql = format!(
1125 "SELECT npub, MAX(created_at) FROM events \
1126 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1127 GROUP BY npub"
1128 );
1129 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1130 let rows = stmt
1131 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1132 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1133 })
1134 .map_err(|e| e.to_string())?;
1135 for row in rows {
1136 let (npub, at) = row.map_err(|e| e.to_string())?;
1137 active.insert(npub, at);
1138 }
1139 }
1140
1141 {
1143 let sql = format!(
1144 "SELECT npub, created_at, tags FROM events \
1145 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1146 );
1147 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1148 let rows = stmt
1149 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1150 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1151 })
1152 .map_err(|e| e.to_string())?;
1153 for row in rows {
1154 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1155 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1157 .ok()
1158 .and_then(|tags| {
1159 tags.into_iter()
1160 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1161 .and_then(|t| t.into_iter().nth(1))
1162 });
1163 match etype.as_deref() {
1164 Some("1") => {
1165 let e = active.entry(npub).or_insert(0);
1166 if at > *e { *e = at; }
1167 }
1168 Some("0") => {
1169 let e = left.entry(npub).or_insert(0);
1170 if at > *e { *e = at; }
1171 }
1172 _ => {}
1173 }
1174 }
1175 }
1176 }
1177
1178 let banned: std::collections::HashSet<String> = community
1181 .channels
1182 .first()
1183 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1184 .unwrap_or_default();
1185
1186 let mut out: Vec<(String, u64)> = active
1188 .into_iter()
1189 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1190 .collect();
1191
1192 {
1199 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1200 let mut reassert = |npub: String| {
1201 if !banned.contains(&npub) && present.insert(npub.clone()) {
1202 out.push((npub, now_secs() as u64));
1203 }
1204 };
1205 if let Some(o) = owner_b32 {
1206 reassert(o);
1207 }
1208 if let Ok(roles) = get_community_roles(community_id) {
1209 for g in &roles.grants {
1210 if g.role_ids.is_empty() {
1211 continue; }
1213 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1214 reassert(b32);
1215 }
1216 }
1217 }
1218 }
1219 out.sort_by(|a, b| b.1.cmp(&a.1));
1220 if capped {
1221 out.truncate(COMMUNITY_MEMBER_CAP);
1222 }
1223 Ok(out)
1224}
1225
1226pub fn community_invite_join_counts(
1231 community_id: &str,
1232 inviter_npub: &str,
1233) -> Result<std::collections::HashMap<String, u64>, String> {
1234 use std::collections::{HashMap, HashSet};
1235 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1236 Some(c) => c,
1237 None => return Ok(HashMap::new()),
1238 };
1239 let mut chat_ints: Vec<i64> = Vec::new();
1240 for ch in &community.channels {
1241 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1242 chat_ints.push(cid);
1243 }
1244 }
1245 if chat_ints.is_empty() {
1246 return Ok(HashMap::new());
1247 }
1248 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1249 let conn = super::get_db_connection_guard_static()?;
1250 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1251 let sql = format!(
1252 "SELECT npub, tags FROM events \
1253 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1254 );
1255 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1256 let rows = stmt
1257 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1258 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1259 })
1260 .map_err(|e| e.to_string())?;
1261 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1263 for row in rows {
1264 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1265 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1266 Ok(t) => t,
1267 Err(_) => continue,
1268 };
1269 let tag_val = |key: &str| -> Option<String> {
1270 tags.iter()
1271 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1272 .and_then(|t| t.get(1).cloned())
1273 };
1274 if tag_val("event-type").as_deref() != Some("1") {
1276 continue;
1277 }
1278 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1279 continue;
1280 }
1281 if let Some(label) = tag_val("invited-label") {
1282 per_label.entry(label).or_default().insert(joiner);
1283 }
1284 }
1285 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1286}
1287
1288static BANLIST_CACHE: std::sync::LazyLock<
1300 std::sync::RwLock<std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>>,
1301> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1302
1303fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1304 hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1305}
1306
1307pub fn clear_banlist_cache() {
1309 BANLIST_CACHE.write().unwrap().clear();
1310}
1311
1312pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1315 if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1316 return std::sync::Arc::clone(set);
1317 }
1318 let set = std::sync::Arc::new(banlist_set_from_hexes(
1319 &get_community_banlist(community_id).unwrap_or_default(),
1320 ));
1321 BANLIST_CACHE
1322 .write()
1323 .unwrap()
1324 .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1325 set
1326}
1327
1328pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1331 let set = banned_set(community_id);
1332 !set.is_empty() && set.contains(&author.to_bytes())
1333}
1334
1335pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1336 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1337 let conn = super::get_write_connection_guard_static()?;
1338 conn.execute(
1339 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1340 params![json, at, community_id],
1341 )
1342 .map_err(|e| format!("set banlist: {e}"))?;
1343 BANLIST_CACHE
1346 .write()
1347 .unwrap()
1348 .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1349 Ok(())
1350}
1351
1352pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1357 let conn = super::get_db_connection_guard_static()?;
1358 let json: Option<String> = conn
1359 .query_row(
1360 "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1361 params![community_id],
1362 |r| r.get(0),
1363 )
1364 .optional()
1365 .map_err(|e| format!("get ban marks: {e}"))?;
1366 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1367}
1368
1369pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1374 if marks.is_empty() {
1375 return Ok(false);
1376 }
1377 let mut stored = get_community_ban_marks(community_id)?;
1378 let mut changed = false;
1379 for (npub, at) in marks {
1380 let slot = stored.entry(npub.clone()).or_insert(0);
1381 if *at > *slot {
1382 *slot = *at;
1383 changed = true;
1384 }
1385 }
1386 if !changed {
1387 return Ok(false);
1388 }
1389 let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1390 let conn = super::get_write_connection_guard_static()?;
1391 conn.execute(
1392 "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1393 params![json, community_id],
1394 )
1395 .map_err(|e| format!("set ban marks: {e}"))?;
1396 Ok(true)
1397}
1398
1399pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1402 let conn = super::get_db_connection_guard_static()?;
1403 let at: Option<i64> = conn
1404 .query_row(
1405 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1406 params![community_id],
1407 |r| r.get(0),
1408 )
1409 .optional()
1410 .map_err(|e| format!("get banlist_at: {e}"))?;
1411 Ok(at.unwrap_or(0))
1412}
1413
1414pub fn set_community_roles(
1419 community_id: &str,
1420 roles: &crate::community::roles::CommunityRoles,
1421 at: i64,
1422) -> Result<(), String> {
1423 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1424 let conn = super::get_write_connection_guard_static()?;
1425 conn.execute(
1426 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1427 params![json, at, community_id],
1428 )
1429 .map_err(|e| format!("set roles: {e}"))?;
1430 Ok(())
1431}
1432
1433pub fn get_community_roles(
1435 community_id: &str,
1436) -> Result<crate::community::roles::CommunityRoles, String> {
1437 let conn = super::get_db_connection_guard_static()?;
1438 let json: Option<String> = conn
1439 .query_row(
1440 "SELECT roles FROM communities WHERE community_id = ?1",
1441 params![community_id],
1442 |r| r.get(0),
1443 )
1444 .optional()
1445 .map_err(|e| format!("get roles: {e}"))?;
1446 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1447}
1448
1449pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1451 let conn = super::get_db_connection_guard_static()?;
1452 let at: Option<i64> = conn
1453 .query_row(
1454 "SELECT roles_at FROM communities WHERE community_id = ?1",
1455 params![community_id],
1456 |r| r.get(0),
1457 )
1458 .optional()
1459 .map_err(|e| format!("get roles_at: {e}"))?;
1460 Ok(at.unwrap_or(0))
1461}
1462
1463pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1468 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1469}
1470
1471pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1474 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1475}
1476
1477pub 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> {
1482 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1483}
1484
1485fn 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> {
1486 let conn = super::get_write_connection_guard_static()?;
1487 conn.execute(
1493 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1494 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1495 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1496 version = excluded.version,
1497 self_hash = excluded.self_hash,
1498 inner_id = excluded.inner_id,
1499 epoch = excluded.epoch
1500 WHERE excluded.epoch > community_edition_heads.epoch
1501 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1502 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)],
1503 )
1504 .map_err(|e| format!("set edition head: {e}"))?;
1505 Ok(())
1506}
1507
1508pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1518 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1519}
1520
1521pub 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> {
1525 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1526}
1527
1528fn 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> {
1529 let conn = super::get_write_connection_guard_static()?;
1530 conn.execute(
1533 "UPDATE community_edition_heads
1534 SET self_hash = ?4, inner_id = ?5
1535 WHERE community_id = ?1 AND entity_id = ?2
1536 AND version = ?3
1537 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1538 AND (inner_id IS NULL OR ?5 < inner_id)",
1539 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1540 )
1541 .map_err(|e| format!("converge edition head: {e}"))?;
1542 Ok(())
1543}
1544
1545pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1550 let conn = super::get_db_connection_guard_static()?;
1551 let row: Option<Option<Vec<u8>>> = conn
1552 .query_row(
1553 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1554 params![community_id, entity_id],
1555 |r| r.get(0),
1556 )
1557 .optional()
1558 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1559 match row.flatten() {
1560 Some(blob) if blob.len() == 32 => {
1561 let mut h = [0u8; 32];
1562 h.copy_from_slice(&blob);
1563 Ok(Some(h))
1564 }
1565 _ => Ok(None),
1566 }
1567}
1568
1569pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1572 let conn = super::get_db_connection_guard_static()?;
1573 let row: Option<(i64, Vec<u8>)> = conn
1574 .query_row(
1575 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1576 params![community_id, entity_id],
1577 |r| Ok((r.get(0)?, r.get(1)?)),
1578 )
1579 .optional()
1580 .map_err(|e| format!("get edition head: {e}"))?;
1581 match row {
1582 Some((v, hash)) if hash.len() == 32 => {
1583 let mut h = [0u8; 32];
1584 h.copy_from_slice(&hash);
1585 Ok(Some((v as u64, h)))
1586 }
1587 _ => Ok(None),
1588 }
1589}
1590
1591pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1596 let conn = super::get_db_connection_guard_static()?;
1597 let mut stmt = conn
1598 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1599 .map_err(|e| e.to_string())?;
1600 let rows = stmt
1601 .query_map(params![community_id], |r| r.get::<_, String>(0))
1602 .map_err(|e| e.to_string())?;
1603 let mut out = std::collections::HashSet::new();
1604 for row in rows {
1605 out.insert(row.map_err(|e| e.to_string())?);
1606 }
1607 Ok(out)
1608}
1609
1610
1611pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1617 let conn = super::get_db_connection_guard_static()?;
1618 let mut stmt = conn
1619 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1620 .map_err(|e| e.to_string())?;
1621 let rows = stmt
1622 .query_map(params![community_id], |r| {
1623 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1624 })
1625 .map_err(|e| e.to_string())?;
1626 let mut out = std::collections::HashMap::new();
1627 for row in rows {
1628 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1629 if hash.len() == 32 {
1630 let mut h = [0u8; 32];
1631 h.copy_from_slice(&hash);
1632 out.insert(entity, (version as u64, h));
1633 }
1634 }
1635 Ok(out)
1636}
1637
1638pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1646 let conn = super::get_db_connection_guard_static()?;
1647 let mut stmt = conn
1648 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1649 .map_err(|e| e.to_string())?;
1650 let rows = stmt
1651 .query_map(params![community_id], |r| {
1652 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1653 })
1654 .map_err(|e| e.to_string())?;
1655 let mut out = std::collections::HashMap::new();
1656 for row in rows {
1657 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1658 if hash.len() == 32 {
1659 let mut h = [0u8; 32];
1660 h.copy_from_slice(&hash);
1661 let inner_id = inner.and_then(|b| {
1662 (b.len() == 32).then(|| {
1663 let mut i = [0u8; 32];
1664 i.copy_from_slice(&b);
1665 i
1666 })
1667 });
1668 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1669 }
1670 }
1671 Ok(out)
1672}
1673
1674pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1675 let conn = super::get_db_connection_guard_static()?;
1676 let mut stmt = conn
1677 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1678 .map_err(|e| e.to_string())?;
1679 let rows = stmt
1680 .query_map(params![community_id], |r| {
1681 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1682 })
1683 .map_err(|e| e.to_string())?;
1684 let mut out = std::collections::HashMap::new();
1685 for row in rows {
1686 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1687 if hash.len() == 32 {
1688 let mut h = [0u8; 32];
1689 h.copy_from_slice(&hash);
1690 out.insert(entity, (epoch as u64, version as u64, h));
1691 }
1692 }
1693 Ok(out)
1694}
1695
1696pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1698 let conn = super::get_db_connection_guard_static()?;
1699 let json: Option<String> = conn
1700 .query_row(
1701 "SELECT banlist FROM communities WHERE community_id = ?1",
1702 params![community_id],
1703 |r| r.get(0),
1704 )
1705 .optional()
1706 .map_err(|e| format!("get banlist: {e}"))?;
1707 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1708}
1709
1710pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1714 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1715 let conn = super::get_write_connection_guard_static()?;
1716 conn.execute(
1717 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1718 params![json, community_id],
1719 )
1720 .map_err(|e| format!("set invite registry: {e}"))?;
1721 Ok(())
1722}
1723
1724pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1727 let conn = super::get_db_connection_guard_static()?;
1728 let json: Option<String> = conn
1729 .query_row(
1730 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1731 params![community_id],
1732 |r| r.get(0),
1733 )
1734 .optional()
1735 .map_err(|e| format!("get invite registry: {e}"))?;
1736 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1737}
1738
1739pub struct InviteLinkSetRow {
1742 pub creator_hex: String,
1743 pub locators: Vec<String>,
1744}
1745
1746pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1750 let mut conn = super::get_write_connection_guard_static()?;
1751 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1752 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1753 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1754 for s in sets {
1755 if s.locators.is_empty() {
1756 continue; }
1758 let enc_creator = enc_txt(&s.creator_hex)?;
1759 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1760 tx.execute(
1763 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1764 params![community_id, enc_creator, enc_locators],
1765 )
1766 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1767 }
1768 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1769 Ok(())
1770}
1771
1772pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1775 let conn = super::get_write_connection_guard_static()?;
1776 let existing_rowid: Option<i64> = {
1778 let mut stmt = conn
1779 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1780 .map_err(|e| e.to_string())?;
1781 let rows = stmt
1782 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1783 .map_err(|e| e.to_string())?;
1784 let mut found = None;
1785 for row in rows {
1786 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1787 if dec_txt(&stored) == creator_hex {
1788 found = Some(rowid);
1789 break;
1790 }
1791 }
1792 found
1793 };
1794 if locators.is_empty() {
1795 if let Some(rowid) = existing_rowid {
1796 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1797 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1798 }
1799 return Ok(());
1800 }
1801 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1802 match existing_rowid {
1803 Some(rowid) => {
1804 conn.execute(
1805 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1806 params![enc_locators, rowid],
1807 )
1808 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1809 }
1810 None => {
1811 let enc_creator = enc_txt(creator_hex)?;
1812 conn.execute(
1813 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1814 params![community_id, enc_creator, enc_locators],
1815 )
1816 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1817 }
1818 }
1819 Ok(())
1820}
1821
1822pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1825 let conn = super::get_db_connection_guard_static()?;
1826 let mut stmt = conn
1827 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1828 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1829 let rows = stmt
1830 .query_map(params![community_id], |r| {
1831 let creator_hex: String = r.get(0)?;
1832 let json: String = r.get(1)?;
1833 Ok((creator_hex, json))
1834 })
1835 .map_err(|e| format!("query invite-link-sets: {e}"))?;
1836 let mut out = Vec::new();
1837 for row in rows {
1838 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1839 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1840 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1841 }
1842 Ok(out)
1843}
1844
1845pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1849 let conn = super::get_write_connection_guard_static()?;
1850 conn.execute(
1851 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1852 params![pending as i64, community_id],
1853 )
1854 .map_err(|e| format!("set read_cut_pending: {e}"))?;
1855 Ok(())
1856}
1857
1858pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
1866 let conn = super::get_write_connection_guard_static()?;
1867 let changed = conn
1868 .execute(
1869 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
1870 params![community_id],
1871 )
1872 .map_err(|e| format!("set dissolved: {e}"))?;
1873 Ok(changed > 0)
1874}
1875
1876pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
1884 let conn = super::get_write_connection_guard_static()?;
1885 let wrapped = enc_txt(payload_json)?;
1886 conn.execute(
1887 "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
1888 params![community_id, wrapped],
1889 )
1890 .map_err(|e| format!("set migration pointer: {e}"))?;
1891 Ok(())
1892}
1893
1894pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
1896 let conn = super::get_db_connection_guard_static()?;
1897 let v: Option<Option<String>> = conn
1898 .query_row(
1899 "SELECT migration_pointer FROM communities WHERE community_id = ?1",
1900 params![community_id],
1901 |r| r.get(0),
1902 )
1903 .optional()
1904 .map_err(|e| format!("get migration pointer: {e}"))?;
1905 Ok(v.flatten().map(|s| dec_txt(&s)))
1906}
1907
1908pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
1911 let conn = super::get_write_connection_guard_static()?;
1912 conn.execute(
1913 "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
1914 params![community_id, v2_community_id],
1915 )
1916 .map_err(|e| format!("set migrated_to: {e}"))?;
1917 Ok(())
1918}
1919
1920pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
1922 let conn = super::get_db_connection_guard_static()?;
1923 let v: Option<Option<String>> = conn
1924 .query_row(
1925 "SELECT migrated_to FROM communities WHERE community_id = ?1",
1926 params![community_id],
1927 |r| r.get(0),
1928 )
1929 .optional()
1930 .map_err(|e| format!("get migrated_to: {e}"))?;
1931 Ok(v.flatten())
1932}
1933
1934pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
1938 let conn = super::get_write_connection_guard_static()?;
1939 conn.execute(
1940 "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
1941 params![community_id],
1942 )
1943 .map_err(|e| format!("set migration checked: {e}"))?;
1944 Ok(())
1945}
1946
1947pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
1954 let conn = super::get_write_connection_guard_static()?;
1955 let wrapped = enc_txt(twin_json)?;
1956 conn.execute(
1957 "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
1958 VALUES (?1, ?2, ?3, ?4, 0)
1959 ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4",
1960 params![v1_community_id, v2_community_id, phase, wrapped],
1961 )
1962 .map_err(|e| format!("set migration ledger: {e}"))?;
1963 Ok(())
1964}
1965
1966pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
1968 let conn = super::get_db_connection_guard_static()?;
1969 let row = conn
1970 .query_row(
1971 "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
1972 params![v1_community_id],
1973 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
1974 )
1975 .optional()
1976 .map_err(|e| format!("get migration ledger: {e}"))?;
1977 Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
1978}
1979
1980pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
1990 let conn = super::get_write_connection_guard_static()?;
1991 let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
1992 tx.execute(
1993 "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
1994 params![v1_community_id, v2_community_id],
1995 )
1996 .map_err(|e| format!("reparent channels: {e}"))?;
1997 tx.execute(
2001 "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2002 params![v1_community_id, v2_community_id],
2003 )
2004 .map_err(|e| format!("set fence: {e}"))?;
2005 tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2006 forget_community_channels(v1_community_id);
2010 Ok(())
2011}
2012
2013pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2016 let conn = super::get_db_connection_guard_static()?;
2017 let mut stmt = conn
2018 .prepare(
2019 "SELECT community_id FROM communities
2020 WHERE dissolved = 1 AND migrated_to IS NULL AND migration_checked = 0",
2021 )
2022 .map_err(|e| e.to_string())?;
2023 let rows = stmt
2024 .query_map([], |r| r.get::<_, String>(0))
2025 .map_err(|e| e.to_string())?;
2026 Ok(rows.flatten().collect())
2027}
2028
2029pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2033 let conn = super::get_db_connection_guard_static()?;
2034 let mut stmt = conn
2035 .prepare(
2036 "SELECT community_id FROM communities
2037 WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2038 )
2039 .map_err(|e| e.to_string())?;
2040 let rows = stmt
2041 .query_map([], |r| r.get::<_, String>(0))
2042 .map_err(|e| e.to_string())?;
2043 Ok(rows.flatten().collect())
2044}
2045
2046pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2049 let conn = super::get_db_connection_guard_static()?;
2050 let v: Option<i64> = conn
2051 .query_row(
2052 "SELECT dissolved FROM communities WHERE community_id = ?1",
2053 params![community_id],
2054 |r| r.get(0),
2055 )
2056 .optional()
2057 .map_err(|e| format!("get dissolved: {e}"))?;
2058 Ok(v.unwrap_or(0) != 0)
2059}
2060
2061pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2064 let conn = super::get_db_connection_guard_static()?;
2065 let v: Option<i64> = conn
2066 .query_row(
2067 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2068 params![community_id],
2069 |r| r.get(0),
2070 )
2071 .optional()
2072 .map_err(|e| format!("get read_cut_pending: {e}"))?;
2073 Ok(v.unwrap_or(0) != 0)
2074}
2075
2076pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2081 let conn = super::get_write_connection_guard_static()?;
2082 conn.execute(
2083 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2084 params![target as i64, community_id],
2085 )
2086 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2087 Ok(())
2088}
2089
2090pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2093 let conn = super::get_db_connection_guard_static()?;
2094 let v: Option<i64> = conn
2095 .query_row(
2096 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2097 params![community_id],
2098 |r| r.get(0),
2099 )
2100 .optional()
2101 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2102 Ok(v.unwrap_or(0) as u64)
2103}
2104
2105pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2108 let conn = super::get_db_connection_guard_static()?;
2109 let v: Option<i64> = conn
2110 .query_row(
2111 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2112 params![community_id, channel_id],
2113 |r| r.get(0),
2114 )
2115 .optional()
2116 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2117 Ok(v.unwrap_or(0) as u64)
2118}
2119
2120pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2124 let conn = super::get_write_connection_guard_static()?;
2125 conn.execute(
2126 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2127 params![server_epoch as i64, community_id, channel_id],
2128 )
2129 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2130 Ok(())
2131}
2132
2133pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2135 let conn = super::get_db_connection_guard_static()?;
2136 let mut stmt = conn
2137 .prepare("SELECT community_id FROM communities ORDER BY created_at")
2138 .map_err(|e| e.to_string())?;
2139 let rows = stmt
2140 .query_map([], |r| r.get::<_, String>(0))
2141 .map_err(|e| e.to_string())?;
2142 let mut ids = Vec::new();
2143 for row in rows {
2144 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2145 }
2146 Ok(ids)
2147}
2148
2149pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2160 let conn = super::get_db_connection_guard_static()?;
2161 let n: Option<i64> = conn
2162 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2163 .optional()
2164 .map_err(|e| e.to_string())?;
2165 Ok(n.map(crate::community::ConcordProtocol::from_i64))
2166}
2167
2168#[derive(serde::Serialize, serde::Deserialize, Default)]
2171struct CommunityMetaStash {
2172 #[serde(default, skip_serializing_if = "Option::is_none")]
2173 custom: Option<serde_json::Map<String, serde_json::Value>>,
2174 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2175 extra: serde_json::Map<String, serde_json::Value>,
2176}
2177
2178#[derive(serde::Serialize, serde::Deserialize, Default)]
2180struct ChannelMetaStash {
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2182 voice: Option<bool>,
2183 #[serde(default, skip_serializing_if = "Option::is_none")]
2184 custom: Option<serde_json::Map<String, serde_json::Value>>,
2185 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2186 extra: serde_json::Map<String, serde_json::Value>,
2187}
2188
2189pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2192 let conn = super::get_write_connection_guard_static()?;
2193 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2194 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2195 let created = (c.created_at_ms / 1000) as i64;
2196
2197 let enc_root = enc_key(&c.community_root)?;
2198 let enc_name = enc_txt(&c.name)?;
2199 let enc_relays = enc_txt(&relays_json)?;
2200 let enc_desc = enc_txt_opt(&c.description)?;
2201 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2202 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2203 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2207 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2208 let enc_icon = enc_txt_opt(&icon_json)?;
2209 let enc_banner = enc_txt_opt(&banner_json)?;
2210 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2211 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2212 .transpose()?;
2213 let enc_stash = enc_txt_opt(&stash_json)?;
2214
2215 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2216 tx.execute(
2217 "INSERT INTO communities
2218 (community_id, server_root_key, name, relays, created_at, description,
2219 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
2220 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
2221 ON CONFLICT(community_id) DO UPDATE SET
2222 server_root_key=?2, name=?3, relays=?4, description=?6,
2223 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2224 icon=?11, banner=?12, meta_extra=?13",
2225 params![
2226 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2227 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2228 enc_icon, enc_banner, enc_stash,
2229 ],
2230 )
2231 .map_err(|e| format!("save v2 community: {e}"))?;
2232
2233 for ch in &c.channels {
2234 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2235 let owner_of: Option<String> = tx
2241 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2242 .optional()
2243 .map_err(|e| format!("channel ownership check: {e}"))?;
2244 if owner_of.is_some_and(|existing| existing != id_hex) {
2245 continue;
2251 }
2252 let stored_key = ch.key.unwrap_or(c.community_root);
2256 let enc_ch_key = enc_key(&stored_key)?;
2257 let enc_ch_name = enc_txt(&ch.name)?;
2258 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2259 .then(|| {
2260 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2261 .map_err(|e| e.to_string())
2262 })
2263 .transpose()?;
2264 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2265 tx.execute(
2266 "INSERT INTO community_channels
2267 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2268 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2269 ON CONFLICT(channel_id) DO UPDATE SET
2270 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2271 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2272 )
2273 .map_err(|e| format!("save v2 channel: {e}"))?;
2274 }
2275
2276 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2281 if keep.is_empty() {
2282 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2283 .map_err(|e| format!("prune v2 channels: {e}"))?;
2284 } else {
2285 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
2286 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2287 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2288 binds.push(id_hex.clone());
2289 binds.extend(keep);
2290 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2291 .map_err(|e| format!("prune v2 channels: {e}"))?;
2292 }
2293
2294 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2295 forget_community_channels(&id_hex);
2298 Ok(())
2299}
2300
2301pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2303 use crate::community::v2::community::{ChannelV2, CommunityV2};
2304 use crate::community::v2::control::CommunityIdentity;
2305 let conn = super::get_db_connection_guard_static()?;
2306 let id_hex = id.to_hex();
2307
2308 let row = conn
2309 .query_row(
2310 "SELECT server_root_key, name, relays, created_at, description,
2311 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2312 icon, banner, meta_extra
2313 FROM communities WHERE community_id = ?1",
2314 params![id_hex],
2315 |r| {
2316 Ok((
2317 r.get::<_, Vec<u8>>(0)?,
2318 r.get::<_, String>(1)?,
2319 r.get::<_, String>(2)?,
2320 r.get::<_, i64>(3)?,
2321 r.get::<_, Option<String>>(4)?,
2322 r.get::<_, i64>(5)?,
2323 r.get::<_, i64>(6)?,
2324 r.get::<_, i64>(7)?,
2325 r.get::<_, Option<String>>(8)?,
2326 r.get::<_, Option<String>>(9)?,
2327 r.get::<_, Option<String>>(10)?,
2328 r.get::<_, Option<String>>(11)?,
2329 r.get::<_, Option<String>>(12)?,
2330 ))
2331 },
2332 )
2333 .optional()
2334 .map_err(|e| e.to_string())?;
2335 let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e, icon_e, banner_e, stash_e)) = row
2336 else {
2337 return Ok(None);
2338 };
2339 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2340 return Ok(None);
2341 }
2342 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2343 return Err("v2 community row is missing its owner commitment".to_string());
2344 };
2345
2346 let community_root = dec_key(&root_blob)?;
2347 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2348 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2349 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2350 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2351
2352 let mut channels = Vec::new();
2353 {
2354 let mut stmt = conn
2355 .prepare(
2356 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2357 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2358 )
2359 .map_err(|e| e.to_string())?;
2360 let rows = stmt
2361 .query_map(params![id_hex], |r| {
2362 Ok((
2363 r.get::<_, String>(0)?,
2364 r.get::<_, Vec<u8>>(1)?,
2365 r.get::<_, i64>(2)?,
2366 r.get::<_, String>(3)?,
2367 r.get::<_, i64>(4)?,
2368 r.get::<_, Option<String>>(5)?,
2369 ))
2370 })
2371 .map_err(|e| e.to_string())?;
2372 for row in rows {
2373 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2374 let private = private != 0;
2375 let key = dec_key(&key_blob)?;
2376 let ch_stash: ChannelMetaStash = ch_stash_e
2379 .map(|s| dec_txt(&s))
2380 .and_then(|j| serde_json::from_str(&j).ok())
2381 .unwrap_or_default();
2382 channels.push(ChannelV2 {
2383 id: ChannelId(hex_id_to_32(&ch_hex)?),
2384 name: dec_txt(&name_e),
2385 private,
2386 key: (private && key != community_root).then_some(key),
2393 epoch: Epoch(epoch as u64),
2394 voice: ch_stash.voice,
2395 meta_custom: ch_stash.custom,
2396 meta_extra: ch_stash.extra,
2397 });
2398 }
2399 }
2400
2401 let icon = icon_e
2404 .map(|s| dec_txt(&s))
2405 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2406 let banner = banner_e
2407 .map(|s| dec_txt(&s))
2408 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2409 let stash: CommunityMetaStash = stash_e
2410 .map(|s| dec_txt(&s))
2411 .and_then(|j| serde_json::from_str(&j).ok())
2412 .unwrap_or_default();
2413
2414 Ok(Some(CommunityV2 {
2415 identity,
2416 community_root,
2417 root_epoch: Epoch(root_epoch as u64),
2418 name: dec_txt(&name_e),
2419 description: desc_e.map(|d| dec_txt(&d)),
2420 icon,
2421 banner,
2422 meta_custom: stash.custom,
2423 meta_extra: stash.extra,
2424 relays,
2425 channels,
2426 dissolved: dissolved != 0,
2427 created_at_ms: (created as u64).saturating_mul(1000),
2428 }))
2429}
2430
2431fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2432 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2433 return Err("stored value is not 32-byte hex".to_string());
2434 }
2435 Ok(crate::simd::hex::hex_to_bytes_32(hex))
2436}
2437
2438pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2442 let conn = super::get_db_connection_guard_static()?;
2443 let row = conn
2444 .query_row(
2445 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2446 params![community_id],
2447 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2448 )
2449 .optional()
2450 .map_err(|e| format!("load guestbook: {e}"))?;
2451 let Some((events_e, cursor)) = row else {
2452 return Ok((Vec::new(), 0));
2453 };
2454 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2455 Ok((events, cursor.max(0) as u64))
2456}
2457
2458pub fn set_guestbook(
2461 community_id: &str,
2462 events: &[crate::community::v2::guestbook::GuestbookEvent],
2463 cursor_secs: u64,
2464) -> Result<(), String> {
2465 let conn = super::get_write_connection_guard_static()?;
2466 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2467 let enc = enc_txt(&json)?;
2468 conn.execute(
2469 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2470 VALUES (?1, ?2, ?3)
2471 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2472 params![community_id, enc, cursor_secs as i64],
2473 )
2474 .map_err(|e| format!("save guestbook: {e}"))?;
2475 Ok(())
2476}
2477
2478#[cfg(test)]
2479mod tests {
2480 use super::*;
2481
2482 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2483
2484 fn make_test_npub(n: u32) -> String {
2487 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2488 let mut payload = vec![b'q'; 58];
2489 let mut x = n as u64;
2490 let mut i = 58;
2491 while x > 0 && i > 0 {
2492 i -= 1;
2493 payload[i] = BECH32[(x as usize) % 32];
2494 x /= 32;
2495 }
2496 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2497 }
2498
2499 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2500 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2501 crate::db::close_database();
2502 crate::db::clear_id_caches();
2505 let tmp = tempfile::tempdir().unwrap();
2506 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2507 let account = make_test_npub(n);
2508 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2509 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2510 crate::db::set_current_account(account.clone()).unwrap();
2511 crate::db::init_database(&account).unwrap();
2512 (tmp, guard)
2513 }
2514
2515 #[test]
2516 fn edition_head_round_trips_and_upserts() {
2517 let (_tmp, _guard) = init_test_db();
2518 let cid = "f".repeat(64);
2519 let entity = "a".repeat(64);
2520
2521 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2523
2524 let h1 = [0x11u8; 32];
2526 set_edition_head(&cid, &entity, 1, &h1).unwrap();
2527 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2528
2529 let h2 = [0x22u8; 32];
2531 set_edition_head(&cid, &entity, 2, &h2).unwrap();
2532 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2533
2534 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2537 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2538 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2539 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2540
2541 let other = "b".repeat(64);
2543 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2544 }
2545
2546 #[test]
2547 fn guestbook_round_trips_events_and_cursor() {
2548 let (_tmp, _guard) = init_test_db();
2549 let member = nostr_sdk::prelude::Keys::generate();
2550 let ev = crate::community::v2::guestbook::GuestbookEvent {
2551 rumor_id: [7u8; 32],
2552 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2553 member: member.public_key(),
2554 invited_by: Some(("creator".into(), "label".into())),
2555 at_ms: 1_000,
2556 },
2557 };
2558 let cid = "d".repeat(64);
2559 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2560 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2561 let (events, cursor) = get_guestbook(&cid).unwrap();
2562 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2563 assert_eq!(cursor, 42);
2564 }
2565
2566 #[test]
2567 fn v2_images_round_trip_and_read_as_v1_community_images() {
2568 let (_tmp, _guard) = init_test_db();
2569 let owner = nostr_sdk::prelude::Keys::generate();
2570 let g = crate::community::v2::control::genesis(
2571 &owner,
2572 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2573 1_000,
2574 )
2575 .unwrap();
2576 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2577 let mut extra = serde_json::Map::new();
2578 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2579 c.icon = Some(crate::community::v2::control::ImageRef {
2580 url: "https://blossom.example/abc".into(),
2581 key: "0".repeat(64),
2582 nonce: "1".repeat(32),
2583 hash: "a".repeat(64),
2584 extra,
2585 });
2586 c.meta_custom = Some({
2587 let mut m = serde_json::Map::new();
2588 m.insert("k".into(), serde_json::Value::from("v"));
2589 m
2590 });
2591 c.channels[0].voice = Some(true);
2592 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2593 save_community_v2(&c).unwrap();
2594
2595 let re = load_community_v2(c.id()).unwrap().unwrap();
2597 assert_eq!(re.icon, c.icon);
2598 assert_eq!(re.banner, None);
2599 assert_eq!(re.meta_custom, c.meta_custom);
2601 assert_eq!(re.channels[0].voice, Some(true));
2602 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2603
2604 let v1 = load_community(c.id()).unwrap().unwrap();
2608 let img = v1.icon.expect("v1 reader sees the v2 icon");
2609 assert_eq!(img.url, "https://blossom.example/abc");
2610 assert_eq!(img.ext, "webp");
2611 assert_eq!(img.hash, "a".repeat(64));
2612 }
2613
2614 #[test]
2615 fn server_root_epoch_round_trips() {
2616 let (_tmp, _guard) = init_test_db();
2618 let mut c = Community::create("HQ", "general", vec![]);
2619 save_community(&c).unwrap();
2620 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2621
2622 c.server_root_epoch = Epoch(5);
2623 c.server_root_key = ServerRootKey([0x42u8; 32]);
2624 save_community(&c).unwrap();
2625 let loaded = load_community(&c.id).unwrap().unwrap();
2626 assert_eq!(loaded.server_root_epoch, Epoch(5));
2627 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2628 }
2629
2630 #[test]
2631 fn epoch_key_archive_retains_every_epoch() {
2632 let (_tmp, _guard) = init_test_db();
2635 let cid = "f".repeat(64);
2636 let scope = "a".repeat(64);
2637
2638 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2639 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2640 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2641
2642 let held = held_epoch_keys(&cid, &scope).unwrap();
2643 assert_eq!(held.len(), 3, "all three epoch keys retained");
2644 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2645 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2646 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2647
2648 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2650 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2651
2652 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2654 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2655 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2656
2657 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2660 assert_eq!(
2661 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2662 None,
2663 "epoch 1 under a different scope is not the channel's key"
2664 );
2665 }
2666
2667 #[test]
2668 fn save_community_populates_the_epoch_archive() {
2669 let (_tmp, _guard) = init_test_db();
2672 let c = Community::create("HQ", "general", vec![]);
2673 save_community(&c).unwrap();
2674 let cid = c.id.to_hex();
2675
2676 assert_eq!(
2678 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2679 Some(c.server_root_key.as_bytes())
2680 );
2681 let chan = &c.channels[0];
2683 assert_eq!(
2684 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2685 Some(chan.key.as_bytes())
2686 );
2687 }
2688
2689 #[test]
2690 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2691 let (_tmp, _guard) = init_test_db();
2692 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2695 crate::state::set_encryption_enabled(true);
2696
2697 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2698 c.server_root_key = ServerRootKey([0x42u8; 32]);
2699 c.description = Some("top secret".into());
2700 save_community(&c).unwrap();
2701 let cid = c.id.to_hex();
2702 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2703
2704 {
2707 let conn = crate::db::get_db_connection_guard_static().unwrap();
2708 let (root_len, name, banlist): (i64, String, String) = conn
2709 .query_row(
2710 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2711 params![cid],
2712 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2713 )
2714 .unwrap();
2715 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2716 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2717 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2718 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2719 let key_len: i64 = conn
2720 .query_row(
2721 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2722 params![cid],
2723 |r| r.get(0),
2724 )
2725 .unwrap();
2726 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2727 }
2728
2729 let loaded = load_community(&c.id).unwrap().unwrap();
2731 assert_eq!(loaded.name, "Secret HQ");
2732 assert_eq!(loaded.description.as_deref(), Some("top secret"));
2733 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2734 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2735 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2736
2737 crate::state::set_encryption_enabled(false);
2738 crate::state::ENCRYPTION_KEY.clear(&[]);
2739 }
2740
2741 #[test]
2742 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2743 let (_tmp, _guard) = init_test_db();
2746 crate::state::set_encryption_enabled(false);
2747 let mut c = Community::create("Legacy HQ", "general", vec![]);
2748 c.server_root_key = ServerRootKey([0x42u8; 32]);
2749 save_community(&c).unwrap();
2750
2751 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2752 crate::state::set_encryption_enabled(true);
2753 let loaded = load_community(&c.id).unwrap().unwrap();
2754 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2755 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2756
2757 crate::state::set_encryption_enabled(false);
2758 crate::state::ENCRYPTION_KEY.clear(&[]);
2759 }
2760
2761 #[test]
2762 fn save_and_load_round_trip() {
2763 let (_tmp, _guard) = init_test_db();
2764 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2765 save_community(&original).unwrap();
2766
2767 let loaded = load_community(&original.id).unwrap().expect("present");
2768 assert_eq!(loaded.id, original.id);
2769 assert_eq!(loaded.name, "Vector HQ");
2770 assert_eq!(loaded.relays, original.relays);
2771 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2773 assert_eq!(loaded.channels.len(), 1);
2775 assert_eq!(loaded.channels[0].id, original.channels[0].id);
2776 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2777 assert_eq!(loaded.channels[0].epoch, Epoch(0));
2778 assert_eq!(loaded.channels[0].name, "general");
2779 }
2780
2781 #[test]
2782 fn owner_is_protected_from_the_banlist_a_member_is_not() {
2783 let (_tmp, _guard) = init_test_db();
2784 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2785 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2787 community.owner_attestation = Some(
2788 crate::community::owner::build_owner_attestation_unsigned(
2789 owner_id.public_key(),
2790 &community.id.to_hex(),
2791 )
2792 .finalize(&owner_id)
2793 .unwrap()
2794 .as_json(),
2795 );
2796 save_community(&community).unwrap();
2797
2798 let member = Keys::generate();
2800 set_community_banlist(
2801 &community.id.to_hex(),
2802 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2803 1,
2804 )
2805 .unwrap();
2806
2807 let loaded = load_community(&community.id).unwrap().unwrap();
2808 let ch = &loaded.channels[0];
2809 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2811 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2812 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2814 }
2815
2816 #[test]
2817 fn loaded_keys_actually_decrypt() {
2818 let (_tmp, _guard) = init_test_db();
2821 let original = Community::create("HQ", "general", vec![]);
2822 save_community(&original).unwrap();
2823 let loaded = load_community(&original.id).unwrap().unwrap();
2824
2825 let author = nostr_sdk::prelude::Keys::generate();
2826 let chan = &original.channels[0];
2827 let sealed = crate::community::envelope::seal_message(
2828 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2829 )
2830 .unwrap();
2831 let opened = crate::community::envelope::open_message(
2832 &sealed,
2833 &loaded.channels[0].key,
2834 &loaded.channels[0].id,
2835 loaded.channels[0].epoch,
2836 )
2837 .unwrap();
2838 assert_eq!(opened.content, "persisted!");
2839 }
2840
2841 #[test]
2842 fn member_view_round_trips() {
2843 let (_tmp, _guard) = init_test_db();
2846 let member = Community {
2847 id: CommunityId([7u8; 32]),
2848 server_root_key: ServerRootKey([8u8; 32]),
2849 server_root_epoch: Epoch(0),
2850 name: "Joined".into(),
2851 description: None,
2852 icon: None,
2853 banner: None,
2854 relays: vec!["wss://r".into()],
2855 channels: vec![Channel {
2856 id: ChannelId([9u8; 32]),
2857 key: ChannelKey([10u8; 32]),
2858 epoch: Epoch(0),
2859 name: "general".into(),
2860 banned: Vec::new(),
2861 protected: Vec::new(), roster: Default::default(),
2862 epoch_keys: Vec::new(),
2863 dissolved: false,
2864 }],
2865 owner_attestation: None,
2866 dissolved: false,
2867 };
2868 save_community(&member).unwrap();
2869 let loaded = load_community(&member.id).unwrap().expect("present");
2870 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
2871 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
2872 }
2873
2874 #[test]
2875 fn large_epoch_round_trips_losslessly() {
2876 let (_tmp, _guard) = init_test_db();
2878 let mut c = Community::create("HQ", "g", vec![]);
2879 c.channels[0].epoch = Epoch(u64::MAX - 7);
2880 save_community(&c).unwrap();
2881 let loaded = load_community(&c.id).unwrap().unwrap();
2882 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
2883 }
2884
2885 #[test]
2886 fn malformed_channel_id_row_errors_not_corrupts() {
2887 let (_tmp, _guard) = init_test_db();
2890 let c = Community::create("HQ", "g", vec![]);
2891 save_community(&c).unwrap();
2892 {
2893 let conn = crate::db::get_write_connection_guard_static().unwrap();
2894 conn.execute(
2895 "INSERT OR REPLACE INTO community_channels
2896 (channel_id, community_id, channel_key, epoch, name, created_at)
2897 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
2898 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2899 )
2900 .unwrap();
2901 }
2902 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2903 }
2904
2905 #[test]
2906 fn message_key_store_take_round_trip() {
2907 let (_tmp, _guard) = init_test_db();
2908 let eph = Keys::generate();
2909 let relays = vec!["wss://r.one".to_string()];
2910 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2912
2913 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2914 assert_eq!(
2915 loaded.secret_key().as_secret_bytes(),
2916 eph.secret_key().as_secret_bytes()
2917 );
2918 assert_eq!(outer, "outer_evid");
2919 assert_eq!(r, relays);
2920 assert!(take_message_key("inner_msg_id").unwrap().is_none());
2922 }
2923
2924 #[test]
2925 fn missing_community_is_none() {
2926 let (_tmp, _guard) = init_test_db();
2927 let absent = CommunityId([0x33u8; 32]);
2928 assert!(load_community(&absent).unwrap().is_none());
2929 }
2930
2931 #[test]
2932 fn list_ids_reflects_saved() {
2933 let (_tmp, _guard) = init_test_db();
2934 let a = Community::create("A", "g", vec![]);
2935 let b = Community::create("B", "g", vec![]);
2936 save_community(&a).unwrap();
2937 save_community(&b).unwrap();
2938 let ids = list_community_ids().unwrap();
2939 assert_eq!(ids.len(), 2);
2940 assert!(ids.contains(&a.id) && ids.contains(&b.id));
2941 }
2942
2943 #[test]
2944 fn delete_community_clears_all_local_state() {
2945 let (_tmp, _guard) = init_test_db();
2946 let c = Community::create("HQ", "general", vec!["r1".into()]);
2947 save_community(&c).unwrap();
2948 let cid = c.id.to_hex();
2949 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2950 save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
2951 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2952
2953 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2955
2956 delete_community(&cid).unwrap();
2957 assert!(!community_exists(&c.id).unwrap());
2958 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2959 assert!(list_public_invites(&cid).unwrap().is_empty());
2960 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2961 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2962 }
2963
2964 #[test]
2965 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2966 let (_tmp, _guard) = init_test_db();
2969 let c = Community::create("HQ", "general", vec!["r1".into()]);
2970 save_community(&c).unwrap();
2971 let cid = c.id.to_hex();
2972 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2973 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2974
2975 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2976 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2977 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2978
2979 delete_community_retain_keys(&cid).unwrap();
2980
2981 assert!(!community_exists(&c.id).unwrap());
2983 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2984 assert!(list_public_invites(&cid).unwrap().is_empty());
2985 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2986 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2988 "base epoch keys retained for self-scrub");
2989 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2990 "channel epoch keys retained for self-scrub");
2991 }
2992
2993 #[test]
2994 fn channel_resolves_to_owning_community() {
2995 let (_tmp, _guard) = init_test_db();
2996 let c = Community::create("HQ", "general", vec![]);
2997 save_community(&c).unwrap();
2998 let chan = c.channels[0].id.to_hex();
2999 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3000 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3001 }
3002
3003 #[test]
3004 fn community_exists_reflects_saved() {
3005 let (_tmp, _guard) = init_test_db();
3006 let c = Community::create("A", "g", vec![]);
3007 assert!(!community_exists(&c.id).unwrap());
3008 save_community(&c).unwrap();
3009 assert!(community_exists(&c.id).unwrap());
3010 }
3011
3012 #[test]
3013 fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3014 let (_tmp, _guard) = init_test_db();
3015 let v1 = Community::create("HQ", "general", vec![]);
3016 save_community(&v1).unwrap();
3017 let v1_cid = v1.id.to_hex();
3018 let v2_cid = "ab".repeat(32);
3019 let chan = v1.channels[0].id.to_hex();
3020
3021 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3023 reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3024
3025 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3027 "stale v1 cache entry must not survive the re-parent");
3028 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3029 assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3030
3031 reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3033 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3034 "migrated_to is one-way — a second flip cannot repoint it");
3035 }
3036
3037 #[test]
3038 fn migration_sweep_candidates_are_sealed_unflipped_unchecked() {
3039 let (_tmp, _guard) = init_test_db();
3040 let a = Community::create("A", "g", vec![]);
3041 let b = Community::create("B", "g", vec![]);
3042 let c = Community::create("C", "g", vec![]);
3043 for x in [&a, &b, &c] { save_community(x).unwrap(); }
3044 set_community_dissolved(&a.id.to_hex()).unwrap();
3046 set_community_dissolved(&b.id.to_hex()).unwrap();
3047 set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3048 let cands = migration_sweep_candidates().unwrap();
3049 assert!(cands.contains(&a.id.to_hex()));
3050 assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3051 assert!(!cands.contains(&c.id.to_hex()), "live is not a candidate");
3052 set_migration_checked(&a.id.to_hex()).unwrap();
3054 assert!(!migration_sweep_candidates().unwrap().contains(&a.id.to_hex()));
3055 }
3056
3057 #[test]
3058 fn pending_invite_first_wins_and_round_trips() {
3059 let (_tmp, _guard) = init_test_db();
3060 let cid = "ab".repeat(32);
3061 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3064 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3065 assert!(pending_invite_exists(&cid).unwrap());
3066
3067 let listed = list_pending_invites().unwrap();
3068 assert_eq!(listed.len(), 1);
3069 assert_eq!(listed[0].community_id, cid);
3070 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3071 assert_eq!(listed[0].inviter_npub, "npub1inviter");
3072
3073 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3075 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3076 delete_pending_invite(&cid).unwrap();
3077 assert!(!pending_invite_exists(&cid).unwrap());
3078 assert!(get_pending_invite(&cid).unwrap().is_none());
3079 }
3080
3081 #[test]
3082 fn purge_drops_invites_for_held_communities_only() {
3083 let (_tmp, _guard) = init_test_db();
3084 let held = Community::create("Held", "general", vec![]);
3087 save_community(&held).unwrap();
3088 let held_hex = held.id.to_hex();
3089 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3090 let stranger = "ab".repeat(32);
3092 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3093
3094 let n = purge_pending_invites_for_held_communities().unwrap();
3095 assert_eq!(n, 1, "only the held community's invite is purged");
3096 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3097 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3098 }
3099
3100 #[test]
3101 fn decline_drops_pending_invite() {
3102 let (_tmp, _guard) = init_test_db();
3103 let cid = "cd".repeat(32);
3104 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3105 delete_pending_invite(&cid).unwrap();
3106 assert!(!pending_invite_exists(&cid).unwrap());
3107 }
3108
3109 #[test]
3110 fn pending_invites_are_capped_keeping_the_newest() {
3111 let (_tmp, _guard) = init_test_db();
3112 for i in 0..150u32 {
3117 let cid = format!("{:064x}", i);
3118 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3119 }
3120 let all = list_pending_invites().unwrap();
3121 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3122 for i in 150..400u32 {
3124 let cid = format!("{:064x}", i);
3125 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3126 }
3127 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3128 }
3129
3130 #[test]
3135 fn expired_parked_invites_are_hidden_from_list_and_accept() {
3136 let (_tmp, _guard) = init_test_db();
3137 let now = now_secs();
3138 let live = "aa".repeat(32);
3139 let expired = "bb".repeat(32);
3140 let permanent = "cc".repeat(32);
3141
3142 save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3143 save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3144 save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3147
3148 let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3149 assert!(listed.contains(&live), "an unexpired invite still lists");
3150 assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3151 assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3152
3153 assert!(get_pending_invite(&live).unwrap().is_some());
3154 assert!(get_pending_invite(&permanent).unwrap().is_some());
3155 assert!(
3156 get_pending_invite(&expired).unwrap().is_none(),
3157 "an expired invite must not be redeemable"
3158 );
3159
3160 assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3162 assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3163 assert!(!pending_invite_exists(&expired).unwrap());
3164 assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3165 assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3166 }
3167
3168 #[test]
3171 fn expiry_follows_the_senders_deadline_not_receipt_time() {
3172 let (_tmp, _guard) = init_test_db();
3173 let cid = "de".repeat(32);
3174 save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3177 assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3178 assert!(get_pending_invite(&cid).unwrap().is_none());
3179 }
3180}