1use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
15use nostr_sdk::ToBech32;
16use rusqlite::{params, OptionalExtension};
17
18use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
19
20fn now_secs() -> i64 {
21 std::time::SystemTime::now()
22 .duration_since(std::time::UNIX_EPOCH)
23 .map(|d| d.as_secs() as i64)
24 .unwrap_or(0)
25}
26
27fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> {
28 bytes
29 .try_into()
30 .map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len()))
31}
32
33fn enc_key(k: &[u8; 32]) -> Result<Vec<u8>, String> { crate::crypto::maybe_encrypt_blob(k) }
35fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) }
36fn enc_txt(s: &str) -> Result<String, String> { crate::crypto::maybe_encrypt_text(s) }
37fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) }
38fn enc_txt_opt(s: &Option<String>) -> Result<Option<String>, String> {
40 s.as_deref().map(enc_txt).transpose()
41}
42
43pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> {
47 crate::simd::hex::hex_to_bytes_32_checked(hex)
48 .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len()))
49}
50
51pub fn save_community(community: &Community) -> Result<(), String> {
54 let conn = super::get_write_connection_guard_static()?;
55 let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?;
56 let community_id = community.id.to_hex();
57
58 let icon_json = community
60 .icon
61 .as_ref()
62 .map(|i| serde_json::to_string(i))
63 .transpose()
64 .map_err(|e| e.to_string())?;
65 let banner_json = community
66 .banner
67 .as_ref()
68 .map(|b| serde_json::to_string(b))
69 .transpose()
70 .map_err(|e| e.to_string())?;
71 let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?;
74 let enc_root = enc_key(community.server_root_key.as_bytes())?;
79 let enc_name = enc_txt(&community.name)?;
80 let enc_relays = enc_txt(&relays_json)?;
81 let enc_desc = enc_txt_opt(&community.description)?;
82 let enc_icon = enc_txt_opt(&icon_json)?;
83 let enc_banner = enc_txt_opt(&banner_json)?;
84 let enc_owner = enc_txt_opt(&community.owner_attestation)?;
85 tx.execute(
86 "INSERT INTO communities
87 (community_id, server_root_key, name, relays, created_at,
88 description, icon, banner, owner_attestation, server_root_epoch)
89 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
90 ON CONFLICT(community_id) DO UPDATE SET
91 server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays,
92 description=excluded.description, icon=excluded.icon, banner=excluded.banner,
93 owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch",
94 params![
95 community_id,
96 &enc_root[..],
97 enc_name,
98 enc_relays,
99 now_secs(),
100 enc_desc,
101 enc_icon,
102 enc_banner,
103 enc_owner,
104 community.server_root_epoch.0 as i64,
105 ],
106 )
107 .map_err(|e| format!("save community: {e}"))?;
108
109 store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
111 community.server_root_epoch.0, community.server_root_key.as_bytes())?;
112
113 for channel in &community.channels {
114 let enc_chan_key = enc_key(channel.key.as_bytes())?;
115 let enc_chan_name = enc_txt(&channel.name)?;
116 tx.execute(
119 "INSERT INTO community_channels
120 (channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch)
121 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
122 ON CONFLICT(channel_id) DO UPDATE SET
123 community_id=excluded.community_id, channel_key=excluded.channel_key,
124 epoch=excluded.epoch, name=excluded.name",
125 params![
126 channel.id.to_hex(),
127 community_id,
128 &enc_chan_key[..],
129 channel.epoch.0 as i64,
133 enc_chan_name,
134 now_secs(),
135 community.server_root_epoch.0 as i64,
137 ],
138 )
139 .map_err(|e| format!("save channel: {e}"))?;
140 store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?;
144 }
145 tx.commit().map_err(|e| format!("save community commit: {e}"))?;
146 Ok(())
147}
148
149pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
156 let conn = super::get_write_connection_guard_static()?;
157 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
158}
159
160fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
164 conn: &C,
165 community_id: &str,
166 scope_id: &str,
167 epoch: u64,
168 key: &[u8; 32],
169) -> Result<(), String> {
170 let enc = enc_key(key)?;
171 conn.execute(
172 "INSERT OR REPLACE INTO community_epoch_keys
173 (community_id, scope_id, epoch, key, created_at)
174 VALUES (?1, ?2, ?3, ?4, ?5)",
175 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
177 )
178 .map_err(|e| format!("store epoch key: {e}"))?;
179 Ok(())
180}
181
182pub fn advance_channel_epoch(
189 community_id: &str,
190 channel_id: &str,
191 new_epoch: u64,
192 new_key: &[u8; 32],
193) -> Result<bool, String> {
194 let conn = super::get_write_connection_guard_static()?;
195 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
196 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
198 let cur: Option<i64> = tx
200 .query_row(
201 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
202 params![community_id, channel_id],
203 |r| r.get(0),
204 )
205 .optional()
206 .map_err(|e| format!("read channel head: {e}"))?;
207 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
208 if advanced {
209 let enc = enc_key(new_key)?;
210 tx.execute(
211 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
212 WHERE community_id = ?3 AND channel_id = ?4",
213 params![new_epoch as i64, &enc[..], community_id, channel_id],
214 )
215 .map_err(|e| format!("advance channel head: {e}"))?;
216 }
217 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
218 Ok(advanced)
219}
220
221pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
228 let conn = super::get_write_connection_guard_static()?;
229 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
230 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
233 let cur: Option<i64> = tx
234 .query_row(
235 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
236 params![community_id],
237 |r| r.get(0),
238 )
239 .optional()
240 .map_err(|e| format!("read server-root epoch: {e}"))?;
241 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
242 if advanced {
243 let enc = enc_key(new_root)?;
244 tx.execute(
245 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
246 params![new_epoch as i64, &enc[..], community_id],
247 )
248 .map_err(|e| format!("advance server-root head: {e}"))?;
249 }
250 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
251 Ok(advanced)
252}
253
254pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
261 let conn = super::get_write_connection_guard_static()?;
262 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
263 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
264 let enc = enc_key(new_root)?;
265 let switched = tx
266 .execute(
267 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
268 params![&enc[..], community_id, epoch as i64],
269 )
270 .map_err(|e| format!("converge server-root head: {e}"))?
271 > 0;
272 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
273 Ok(switched)
274}
275
276pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
281 let conn = super::get_write_connection_guard_static()?;
282 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
283 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
284 let enc = enc_key(new_key)?;
285 let switched = tx
286 .execute(
287 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
288 params![&enc[..], community_id, channel_id, epoch as i64],
289 )
290 .map_err(|e| format!("converge channel head: {e}"))?
291 > 0;
292 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
293 Ok(switched)
294}
295
296pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
300 let conn = super::get_db_connection_guard_static()?;
301 let mut stmt = conn
302 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
303 .map_err(|e| e.to_string())?;
304 let rows = stmt
305 .query_map(params![community_id, scope_id], |r| {
306 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
307 })
308 .map_err(|e| e.to_string())?;
309 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
310 for row in rows {
311 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
312 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
313 }
314 out.sort_by_key(|(e, _)| e.0);
315 Ok(out)
316}
317
318pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
321 let conn = super::get_db_connection_guard_static()?;
322 let blob: Option<Vec<u8>> = conn
323 .query_row(
324 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
325 params![community_id, scope_id, epoch as i64],
326 |r| r.get(0),
327 )
328 .optional()
329 .map_err(|e| format!("held epoch key: {e}"))?;
330 blob.map(|b| dec_key(&b)).transpose()
331}
332
333pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
337 let conn = super::get_db_connection_guard_static().ok()?;
338 conn.query_row(
339 "SELECT created_at FROM communities WHERE community_id = ?1",
340 params![id.to_hex()],
341 |r| r.get::<_, i64>(0),
342 )
343 .optional()
344 .ok()
345 .flatten()
346 .map(|secs| (secs.max(0) as u64) * 1000)
347}
348
349pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
351 let conn = super::get_db_connection_guard_static()?;
352 let id_hex = id.to_hex();
353
354 let row = conn
355 .query_row(
356 "SELECT server_root_key, name, relays,
357 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
358 FROM communities WHERE community_id = ?1",
359 params![id_hex],
360 |r| {
361 Ok((
362 r.get::<_, Vec<u8>>(0)?,
363 r.get::<_, String>(1)?,
364 r.get::<_, String>(2)?,
365 r.get::<_, Option<String>>(3)?,
366 r.get::<_, Option<String>>(4)?,
367 r.get::<_, Option<String>>(5)?,
368 r.get::<_, String>(6)?,
369 r.get::<_, Option<String>>(7)?,
370 r.get::<_, i64>(8)?,
371 r.get::<_, i64>(9)?,
372 ))
373 },
374 )
375 .optional()
376 .map_err(|e| format!("load community: {e}"))?;
377
378 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
379 match row {
380 Some(t) => t,
381 None => return Ok(None),
382 };
383 let dissolved = dissolved_int != 0;
384
385 let name = dec_txt(&name);
387 let relays_json = dec_txt(&relays_json);
388 let description = description.map(|s| dec_txt(&s));
389 let icon_json = icon_json.map(|s| dec_txt(&s));
390 let banner_json = banner_json.map(|s| dec_txt(&s));
391 let banlist_json = dec_txt(&banlist_json);
392 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
393
394 let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
398 .unwrap_or_default()
399 .iter()
400 .filter_map(|h| PublicKey::from_hex(h).ok())
401 .collect();
402
403 let icon = icon_json
404 .map(|j| serde_json::from_str(&j))
405 .transpose()
406 .map_err(|e| format!("icon json: {e}"))?;
407 let banner = banner_json
408 .map(|j| serde_json::from_str(&j))
409 .transpose()
410 .map_err(|e| format!("banner json: {e}"))?;
411
412 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
413 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
414
415 let mut protected: Vec<PublicKey> = Vec::new();
422 if let Some(owner) = owner_attestation
423 .as_ref()
424 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
425 {
426 protected.push(owner);
427 }
428 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
429
430 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
433 let mut stmt = conn
434 .prepare(
435 "SELECT channel_id, channel_key, epoch, name
436 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
437 )
438 .map_err(|e| e.to_string())?;
439 let rows = stmt
440 .query_map(params![id_hex], |r| {
441 Ok((
442 r.get::<_, String>(0)?,
443 r.get::<_, Vec<u8>>(1)?,
444 r.get::<_, i64>(2)?,
445 r.get::<_, String>(3)?,
446 ))
447 })
448 .map_err(|e| e.to_string())?;
449 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
450 };
451
452 let roster = get_community_roles(&id_hex).unwrap_or_default();
455
456 let mut channels = Vec::new();
457 for (cid_hex, key_blob, epoch, cname) in raw_channels {
458 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
462 let mut ek_stmt = conn
463 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
464 .map_err(|e| e.to_string())?;
465 let rows = ek_stmt
466 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
467 .map_err(|e| e.to_string())?;
468 let mut out = Vec::new();
469 for row in rows {
470 let (e, blob) = row.map_err(|e| e.to_string())?;
471 if let Ok(k) = dec_key(&blob) {
472 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
473 }
474 }
475 out
476 };
477 channels.push(Channel {
478 id: ChannelId(hex_id_to_32(&cid_hex)?),
479 key: ChannelKey(dec_key(&key_blob)?),
480 epoch: Epoch(epoch as u64),
483 name: dec_txt(&cname),
484 banned: banned.clone(),
485 protected: protected.clone(),
486 roster: roster.clone(),
487 epoch_keys,
488 dissolved,
489 });
490 }
491
492 Ok(Some(Community {
493 id: *id,
494 server_root_key,
495 server_root_epoch: Epoch(server_root_epoch as u64),
497 name,
498 description,
499 icon,
500 banner,
501 relays,
502 channels,
503 owner_attestation,
504 dissolved,
505 }))
506}
507
508pub fn store_message_key(
511 message_id: &str,
512 outer_event_id: &str,
513 ephemeral: &Keys,
514 relays: &[String],
515) -> Result<(), String> {
516 let conn = super::get_write_connection_guard_static()?;
517 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
518 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
519 let enc_secret = enc_key(&sk_bytes)?;
520 let enc_relays = enc_txt(&relays_json)?;
521 conn.execute(
522 "INSERT OR REPLACE INTO community_message_keys
523 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
524 VALUES (?1, ?2, ?3, ?4, ?5)",
525 params![
526 outer_event_id,
527 message_id,
528 &enc_secret[..],
529 enc_relays,
530 now_secs(),
531 ],
532 )
533 .map_err(|e| format!("store message key: {e}"))?;
534 Ok(())
535}
536
537pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
543 let conn = super::get_db_connection_guard_static()?;
544 let row = conn
545 .query_row(
546 "SELECT ephemeral_secret, outer_event_id, relays
547 FROM community_message_keys WHERE message_id = ?1",
548 params![message_id],
549 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
550 )
551 .optional()
552 .map_err(|e| format!("get message key: {e}"))?;
553 let (secret_blob, outer_event_id, relays_json) = match row {
554 Some(t) => t,
555 None => return Ok(None),
556 };
557 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
558 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
559 Ok(Some((Keys::new(secret), outer_event_id, relays)))
560}
561
562pub fn delete_message_key(message_id: &str) -> Result<(), String> {
564 let conn = super::get_write_connection_guard_static()?;
565 conn.execute(
566 "DELETE FROM community_message_keys WHERE message_id = ?1",
567 params![message_id],
568 )
569 .map_err(|e| format!("remove message key: {e}"))?;
570 Ok(())
571}
572
573pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
576 let r = get_message_key(message_id)?;
577 if r.is_some() {
578 delete_message_key(message_id)?;
579 }
580 Ok(r)
581}
582
583pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
586 let conn = super::get_db_connection_guard_static()?;
587 conn.query_row(
588 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
589 params![channel_id],
590 |r| r.get::<_, String>(0),
591 )
592 .optional()
593 .map_err(|e| format!("community_id_for_channel: {e}"))
594}
595
596pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
599 let conn = super::get_db_connection_guard_static()?;
600 let found: Option<i64> = conn
601 .query_row(
602 "SELECT 1 FROM communities WHERE community_id = ?1",
603 params![id.to_hex()],
604 |r| r.get(0),
605 )
606 .optional()
607 .map_err(|e| format!("community_exists: {e}"))?;
608 Ok(found.is_some())
609}
610
611#[derive(Debug, Clone, serde::Serialize)]
613pub struct PendingCommunityInvite {
614 pub community_id: String,
615 pub bundle_json: String,
616 pub inviter_npub: String,
617 pub received_at: i64,
618}
619
620pub fn save_pending_invite(
625 community_id: &str,
626 bundle_json: &str,
627 inviter_npub: &str,
628) -> Result<bool, String> {
629 const MAX_PENDING_INVITES: usize = 100;
633
634 let conn = super::get_write_connection_guard_static()?;
635 let enc_bundle = enc_txt(bundle_json)?;
636 let enc_inviter = enc_txt(inviter_npub)?;
637 let changed = conn
642 .execute(
643 "INSERT OR IGNORE INTO pending_community_invites
644 (community_id, bundle_json, inviter_npub, received_at)
645 VALUES (?1, ?2, ?3, ?4)",
646 params![community_id, enc_bundle, enc_inviter, now_secs()],
647 )
648 .map_err(|e| format!("save pending invite: {e}"))?;
649 if changed > 0 {
652 let _ = conn.execute(
653 "DELETE FROM pending_community_invites
654 WHERE community_id IN (
655 SELECT community_id FROM pending_community_invites
656 ORDER BY received_at DESC, community_id DESC
657 LIMIT -1 OFFSET ?1
658 )",
659 params![MAX_PENDING_INVITES],
660 );
661 }
662 Ok(changed > 0)
663}
664
665pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
671 let conn = super::get_write_connection_guard_static()?;
672 let n = conn
673 .execute(
674 "DELETE FROM pending_community_invites
675 WHERE community_id IN (SELECT community_id FROM communities)",
676 [],
677 )
678 .map_err(|e| format!("purge held pending invites: {e}"))?;
679 Ok(n)
680}
681
682pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
684 let conn = super::get_db_connection_guard_static()?;
685 let mut stmt = conn
686 .prepare(
687 "SELECT community_id, bundle_json, inviter_npub, received_at
688 FROM pending_community_invites ORDER BY received_at DESC",
689 )
690 .map_err(|e| e.to_string())?;
691 let rows = stmt
692 .query_map([], |r| {
693 Ok(PendingCommunityInvite {
694 community_id: r.get(0)?,
695 bundle_json: dec_txt(&r.get::<_, String>(1)?),
696 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
697 received_at: r.get(3)?,
698 })
699 })
700 .map_err(|e| e.to_string())?;
701 let mut out = Vec::new();
702 for row in rows {
703 out.push(row.map_err(|e| e.to_string())?);
704 }
705 Ok(out)
706}
707
708pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
712 let conn = super::get_db_connection_guard_static()?;
713 let raw: Option<String> = conn
714 .query_row(
715 "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
716 params![community_id],
717 |r| r.get::<_, String>(0),
718 )
719 .optional()
720 .map_err(|e| format!("get pending invite: {e}"))?;
721 Ok(raw.map(|s| dec_txt(&s)))
722}
723
724pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
726 let conn = super::get_write_connection_guard_static()?;
727 conn.execute(
728 "DELETE FROM pending_community_invites WHERE community_id = ?1",
729 params![community_id],
730 )
731 .map_err(|e| format!("delete pending invite: {e}"))?;
732 Ok(())
733}
734
735pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
737 let conn = super::get_db_connection_guard_static()?;
738 let found: Option<i64> = conn
739 .query_row(
740 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
741 params![community_id],
742 |r| r.get(0),
743 )
744 .optional()
745 .map_err(|e| format!("pending_invite_exists: {e}"))?;
746 Ok(found.is_some())
747}
748
749#[derive(Debug, Clone, serde::Serialize)]
751pub struct PublicInviteRecord {
752 pub token: String,
754 pub community_id: String,
755 pub url: String,
756 pub expires_at: Option<i64>,
757 pub created_at: i64,
758 pub label: Option<String>,
760 #[serde(default)]
762 pub join_count: u64,
763}
764
765pub fn save_public_invite(
767 token: &str,
768 community_id: &str,
769 url: &str,
770 expires_at: Option<i64>,
771 label: Option<&str>,
772) -> Result<(), String> {
773 let conn = super::get_write_connection_guard_static()?;
774 let enc_token = enc_txt(token)?;
777 let enc_url = enc_txt(url)?;
778 let enc_label = label.map(enc_txt).transpose()?;
780 conn.execute(
781 "INSERT OR REPLACE INTO community_public_invites
782 (token, community_id, url, expires_at, created_at, label)
783 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
784 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
785 )
786 .map_err(|e| format!("save public invite: {e}"))?;
787 Ok(())
788}
789
790pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
792 let conn = super::get_db_connection_guard_static()?;
793 let mut stmt = conn
794 .prepare(
795 "SELECT token, community_id, url, expires_at, created_at, label
796 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
797 )
798 .map_err(|e| e.to_string())?;
799 let rows = stmt
800 .query_map(params![community_id], |r| {
801 Ok(PublicInviteRecord {
802 token: dec_txt(&r.get::<_, String>(0)?),
803 community_id: r.get(1)?,
804 url: dec_txt(&r.get::<_, String>(2)?),
805 expires_at: r.get(3)?,
806 created_at: r.get(4)?,
807 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
808 join_count: 0,
809 })
810 })
811 .map_err(|e| e.to_string())?;
812 let mut out = Vec::new();
813 for row in rows {
814 out.push(row.map_err(|e| e.to_string())?);
815 }
816 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
818 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
819 for rec in &mut out {
820 if let Some(l) = rec.label.as_deref() {
821 rec.join_count = counts.get(l).copied().unwrap_or(0);
822 }
823 }
824 }
825 }
826 Ok(out)
827}
828
829pub fn delete_public_invite(token: &str) -> Result<(), String> {
831 let conn = super::get_write_connection_guard_static()?;
832 let rows: Vec<(i64, String)> = {
835 let mut stmt = conn
836 .prepare("SELECT rowid, token FROM community_public_invites")
837 .map_err(|e| e.to_string())?;
838 let mapped = stmt
839 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
840 .map_err(|e| e.to_string())?;
841 mapped.filter_map(|r| r.ok()).collect()
842 };
843 for (rowid, stored) in rows {
844 if dec_txt(&stored) == token {
845 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
846 .map_err(|e| format!("delete public invite: {e}"))?;
847 }
848 }
849 Ok(())
850}
851
852pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
854 let conn = super::get_db_connection_guard_static()?;
855 let mut stmt = conn
856 .prepare(
857 "SELECT token, community_id, url, expires_at, created_at, label
858 FROM community_public_invites ORDER BY created_at DESC",
859 )
860 .map_err(|e| e.to_string())?;
861 let rows = stmt
862 .query_map([], |r| {
863 Ok(PublicInviteRecord {
864 token: dec_txt(&r.get::<_, String>(0)?),
865 community_id: r.get(1)?,
866 url: dec_txt(&r.get::<_, String>(2)?),
867 expires_at: r.get(3)?,
868 created_at: r.get(4)?,
869 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
870 join_count: 0,
871 })
872 })
873 .map_err(|e| e.to_string())?;
874 let mut out = Vec::new();
875 for row in rows {
876 out.push(row.map_err(|e| e.to_string())?);
877 }
878 Ok(out)
879}
880
881pub fn upsert_public_invite(
886 token: &str,
887 community_id: &str,
888 url: &str,
889 expires_at: Option<i64>,
890 created_at: i64,
891 label: Option<&str>,
892) -> Result<bool, String> {
893 let conn = super::get_write_connection_guard_static()?;
894 let already = {
895 let mut stmt = conn
896 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
897 .map_err(|e| e.to_string())?;
898 let stored: Vec<String> = stmt
899 .query_map(params![community_id], |r| r.get::<_, String>(0))
900 .map_err(|e| e.to_string())?
901 .filter_map(|r| r.ok())
902 .collect();
903 stored.iter().any(|s| dec_txt(s) == token)
904 };
905 if already {
906 return Ok(false);
907 }
908 let enc_token = enc_txt(token)?;
909 let enc_url = enc_txt(url)?;
910 let enc_label = label.map(enc_txt).transpose()?;
911 conn.execute(
912 "INSERT INTO community_public_invites
913 (token, community_id, url, expires_at, created_at, label)
914 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
915 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
916 )
917 .map_err(|e| format!("upsert public invite: {e}"))?;
918 Ok(true)
919}
920
921pub fn delete_community(community_id: &str) -> Result<(), String> {
926 delete_community_inner(community_id, false)
927}
928
929pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
935 delete_community_inner(community_id, true)
936}
937
938fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
939 let conn = super::get_write_connection_guard_static()?;
940 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
943 for sql in [
944 Some("DELETE FROM communities WHERE community_id = ?1"),
945 Some("DELETE FROM community_channels WHERE community_id = ?1"),
946 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
950 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
951 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
952 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
953 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
956 ]
957 .into_iter()
958 .flatten()
959 {
960 tx.execute(sql, params![community_id])
961 .map_err(|e| format!("delete community: {e}"))?;
962 }
963 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
964 Ok(())
969}
970
971pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
978 const COMMUNITY_MEMBER_CAP: usize = 500;
981 use std::collections::HashMap;
982
983 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
984 Some(c) => c,
985 None => return Ok(Vec::new()),
986 };
987 let owner_b32: Option<String> = community
992 .owner_attestation
993 .as_deref()
994 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
995 .and_then(|pk| pk.to_bech32().ok());
996
997 let mut chat_ints: Vec<i64> = Vec::new();
999 for ch in &community.channels {
1000 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1001 chat_ints.push(cid);
1002 }
1003 }
1004
1005 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1008
1009 let mut active: HashMap<String, u64> = HashMap::new();
1014 let mut left: HashMap<String, u64> = HashMap::new();
1015 if !chat_ints.is_empty() {
1018 let conn = super::get_db_connection_guard_static()?;
1019 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1020
1021 {
1022 let sql = format!(
1023 "SELECT npub, MAX(created_at) FROM events \
1024 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1025 GROUP BY npub"
1026 );
1027 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1028 let rows = stmt
1029 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1030 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1031 })
1032 .map_err(|e| e.to_string())?;
1033 for row in rows {
1034 let (npub, at) = row.map_err(|e| e.to_string())?;
1035 active.insert(npub, at);
1036 }
1037 }
1038
1039 {
1041 let sql = format!(
1042 "SELECT npub, created_at, tags FROM events \
1043 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1044 );
1045 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1046 let rows = stmt
1047 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1048 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1049 })
1050 .map_err(|e| e.to_string())?;
1051 for row in rows {
1052 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1053 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1055 .ok()
1056 .and_then(|tags| {
1057 tags.into_iter()
1058 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1059 .and_then(|t| t.into_iter().nth(1))
1060 });
1061 match etype.as_deref() {
1062 Some("1") => {
1063 let e = active.entry(npub).or_insert(0);
1064 if at > *e { *e = at; }
1065 }
1066 Some("0") => {
1067 let e = left.entry(npub).or_insert(0);
1068 if at > *e { *e = at; }
1069 }
1070 _ => {}
1071 }
1072 }
1073 }
1074 }
1075
1076 let banned: std::collections::HashSet<String> = community
1079 .channels
1080 .first()
1081 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1082 .unwrap_or_default();
1083
1084 let mut out: Vec<(String, u64)> = active
1086 .into_iter()
1087 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1088 .collect();
1089
1090 {
1097 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1098 let mut reassert = |npub: String| {
1099 if !banned.contains(&npub) && present.insert(npub.clone()) {
1100 out.push((npub, now_secs() as u64));
1101 }
1102 };
1103 if let Some(o) = owner_b32 {
1104 reassert(o);
1105 }
1106 if let Ok(roles) = get_community_roles(community_id) {
1107 for g in &roles.grants {
1108 if g.role_ids.is_empty() {
1109 continue; }
1111 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1112 reassert(b32);
1113 }
1114 }
1115 }
1116 }
1117 out.sort_by(|a, b| b.1.cmp(&a.1));
1118 out.truncate(COMMUNITY_MEMBER_CAP);
1119 Ok(out)
1120}
1121
1122pub fn community_invite_join_counts(
1127 community_id: &str,
1128 inviter_npub: &str,
1129) -> Result<std::collections::HashMap<String, u64>, String> {
1130 use std::collections::{HashMap, HashSet};
1131 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1132 Some(c) => c,
1133 None => return Ok(HashMap::new()),
1134 };
1135 let mut chat_ints: Vec<i64> = Vec::new();
1136 for ch in &community.channels {
1137 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1138 chat_ints.push(cid);
1139 }
1140 }
1141 if chat_ints.is_empty() {
1142 return Ok(HashMap::new());
1143 }
1144 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1145 let conn = super::get_db_connection_guard_static()?;
1146 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1147 let sql = format!(
1148 "SELECT npub, tags FROM events \
1149 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1150 );
1151 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1152 let rows = stmt
1153 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1154 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1155 })
1156 .map_err(|e| e.to_string())?;
1157 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1159 for row in rows {
1160 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1161 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1162 Ok(t) => t,
1163 Err(_) => continue,
1164 };
1165 let tag_val = |key: &str| -> Option<String> {
1166 tags.iter()
1167 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1168 .and_then(|t| t.get(1).cloned())
1169 };
1170 if tag_val("event-type").as_deref() != Some("1") {
1172 continue;
1173 }
1174 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1175 continue;
1176 }
1177 if let Some(label) = tag_val("invited-label") {
1178 per_label.entry(label).or_default().insert(joiner);
1179 }
1180 }
1181 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1182}
1183
1184pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1189 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1190 let conn = super::get_write_connection_guard_static()?;
1191 conn.execute(
1192 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1193 params![json, at, community_id],
1194 )
1195 .map_err(|e| format!("set banlist: {e}"))?;
1196 Ok(())
1197}
1198
1199pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1202 let conn = super::get_db_connection_guard_static()?;
1203 let at: Option<i64> = conn
1204 .query_row(
1205 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1206 params![community_id],
1207 |r| r.get(0),
1208 )
1209 .optional()
1210 .map_err(|e| format!("get banlist_at: {e}"))?;
1211 Ok(at.unwrap_or(0))
1212}
1213
1214pub fn set_community_roles(
1219 community_id: &str,
1220 roles: &crate::community::roles::CommunityRoles,
1221 at: i64,
1222) -> Result<(), String> {
1223 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1224 let conn = super::get_write_connection_guard_static()?;
1225 conn.execute(
1226 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1227 params![json, at, community_id],
1228 )
1229 .map_err(|e| format!("set roles: {e}"))?;
1230 Ok(())
1231}
1232
1233pub fn get_community_roles(
1235 community_id: &str,
1236) -> Result<crate::community::roles::CommunityRoles, String> {
1237 let conn = super::get_db_connection_guard_static()?;
1238 let json: Option<String> = conn
1239 .query_row(
1240 "SELECT roles FROM communities WHERE community_id = ?1",
1241 params![community_id],
1242 |r| r.get(0),
1243 )
1244 .optional()
1245 .map_err(|e| format!("get roles: {e}"))?;
1246 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1247}
1248
1249pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1251 let conn = super::get_db_connection_guard_static()?;
1252 let at: Option<i64> = conn
1253 .query_row(
1254 "SELECT roles_at FROM communities WHERE community_id = ?1",
1255 params![community_id],
1256 |r| r.get(0),
1257 )
1258 .optional()
1259 .map_err(|e| format!("get roles_at: {e}"))?;
1260 Ok(at.unwrap_or(0))
1261}
1262
1263pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1268 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1269}
1270
1271pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1274 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1275}
1276
1277pub 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> {
1282 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1283}
1284
1285fn 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> {
1286 let conn = super::get_write_connection_guard_static()?;
1287 conn.execute(
1293 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1294 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1295 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1296 version = excluded.version,
1297 self_hash = excluded.self_hash,
1298 inner_id = excluded.inner_id,
1299 epoch = excluded.epoch
1300 WHERE excluded.epoch > community_edition_heads.epoch
1301 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1302 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)],
1303 )
1304 .map_err(|e| format!("set edition head: {e}"))?;
1305 Ok(())
1306}
1307
1308pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1318 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1319}
1320
1321pub 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> {
1325 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1326}
1327
1328fn 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> {
1329 let conn = super::get_write_connection_guard_static()?;
1330 conn.execute(
1333 "UPDATE community_edition_heads
1334 SET self_hash = ?4, inner_id = ?5
1335 WHERE community_id = ?1 AND entity_id = ?2
1336 AND version = ?3
1337 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1338 AND (inner_id IS NULL OR ?5 < inner_id)",
1339 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1340 )
1341 .map_err(|e| format!("converge edition head: {e}"))?;
1342 Ok(())
1343}
1344
1345pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1350 let conn = super::get_db_connection_guard_static()?;
1351 let row: Option<Option<Vec<u8>>> = conn
1352 .query_row(
1353 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1354 params![community_id, entity_id],
1355 |r| r.get(0),
1356 )
1357 .optional()
1358 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1359 match row.flatten() {
1360 Some(blob) if blob.len() == 32 => {
1361 let mut h = [0u8; 32];
1362 h.copy_from_slice(&blob);
1363 Ok(Some(h))
1364 }
1365 _ => Ok(None),
1366 }
1367}
1368
1369pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1372 let conn = super::get_db_connection_guard_static()?;
1373 let row: Option<(i64, Vec<u8>)> = conn
1374 .query_row(
1375 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1376 params![community_id, entity_id],
1377 |r| Ok((r.get(0)?, r.get(1)?)),
1378 )
1379 .optional()
1380 .map_err(|e| format!("get edition head: {e}"))?;
1381 match row {
1382 Some((v, hash)) if hash.len() == 32 => {
1383 let mut h = [0u8; 32];
1384 h.copy_from_slice(&hash);
1385 Ok(Some((v as u64, h)))
1386 }
1387 _ => Ok(None),
1388 }
1389}
1390
1391pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1396 let conn = super::get_db_connection_guard_static()?;
1397 let mut stmt = conn
1398 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1399 .map_err(|e| e.to_string())?;
1400 let rows = stmt
1401 .query_map(params![community_id], |r| r.get::<_, String>(0))
1402 .map_err(|e| e.to_string())?;
1403 let mut out = std::collections::HashSet::new();
1404 for row in rows {
1405 out.insert(row.map_err(|e| e.to_string())?);
1406 }
1407 Ok(out)
1408}
1409
1410
1411pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1417 let conn = super::get_db_connection_guard_static()?;
1418 let mut stmt = conn
1419 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1420 .map_err(|e| e.to_string())?;
1421 let rows = stmt
1422 .query_map(params![community_id], |r| {
1423 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1424 })
1425 .map_err(|e| e.to_string())?;
1426 let mut out = std::collections::HashMap::new();
1427 for row in rows {
1428 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1429 if hash.len() == 32 {
1430 let mut h = [0u8; 32];
1431 h.copy_from_slice(&hash);
1432 out.insert(entity, (version as u64, h));
1433 }
1434 }
1435 Ok(out)
1436}
1437
1438pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1446 let conn = super::get_db_connection_guard_static()?;
1447 let mut stmt = conn
1448 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1449 .map_err(|e| e.to_string())?;
1450 let rows = stmt
1451 .query_map(params![community_id], |r| {
1452 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1453 })
1454 .map_err(|e| e.to_string())?;
1455 let mut out = std::collections::HashMap::new();
1456 for row in rows {
1457 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1458 if hash.len() == 32 {
1459 let mut h = [0u8; 32];
1460 h.copy_from_slice(&hash);
1461 let inner_id = inner.and_then(|b| {
1462 (b.len() == 32).then(|| {
1463 let mut i = [0u8; 32];
1464 i.copy_from_slice(&b);
1465 i
1466 })
1467 });
1468 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1469 }
1470 }
1471 Ok(out)
1472}
1473
1474pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1475 let conn = super::get_db_connection_guard_static()?;
1476 let mut stmt = conn
1477 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1478 .map_err(|e| e.to_string())?;
1479 let rows = stmt
1480 .query_map(params![community_id], |r| {
1481 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1482 })
1483 .map_err(|e| e.to_string())?;
1484 let mut out = std::collections::HashMap::new();
1485 for row in rows {
1486 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1487 if hash.len() == 32 {
1488 let mut h = [0u8; 32];
1489 h.copy_from_slice(&hash);
1490 out.insert(entity, (epoch as u64, version as u64, h));
1491 }
1492 }
1493 Ok(out)
1494}
1495
1496pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1498 let conn = super::get_db_connection_guard_static()?;
1499 let json: Option<String> = conn
1500 .query_row(
1501 "SELECT banlist FROM communities WHERE community_id = ?1",
1502 params![community_id],
1503 |r| r.get(0),
1504 )
1505 .optional()
1506 .map_err(|e| format!("get banlist: {e}"))?;
1507 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1508}
1509
1510pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1514 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1515 let conn = super::get_write_connection_guard_static()?;
1516 conn.execute(
1517 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1518 params![json, community_id],
1519 )
1520 .map_err(|e| format!("set invite registry: {e}"))?;
1521 Ok(())
1522}
1523
1524pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1527 let conn = super::get_db_connection_guard_static()?;
1528 let json: Option<String> = conn
1529 .query_row(
1530 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1531 params![community_id],
1532 |r| r.get(0),
1533 )
1534 .optional()
1535 .map_err(|e| format!("get invite registry: {e}"))?;
1536 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1537}
1538
1539pub struct InviteLinkSetRow {
1542 pub creator_hex: String,
1543 pub locators: Vec<String>,
1544}
1545
1546pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1550 let mut conn = super::get_write_connection_guard_static()?;
1551 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1552 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1553 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1554 for s in sets {
1555 if s.locators.is_empty() {
1556 continue; }
1558 let enc_creator = enc_txt(&s.creator_hex)?;
1559 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1560 tx.execute(
1563 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1564 params![community_id, enc_creator, enc_locators],
1565 )
1566 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1567 }
1568 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1569 Ok(())
1570}
1571
1572pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1575 let conn = super::get_write_connection_guard_static()?;
1576 let existing_rowid: Option<i64> = {
1578 let mut stmt = conn
1579 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1580 .map_err(|e| e.to_string())?;
1581 let rows = stmt
1582 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1583 .map_err(|e| e.to_string())?;
1584 let mut found = None;
1585 for row in rows {
1586 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1587 if dec_txt(&stored) == creator_hex {
1588 found = Some(rowid);
1589 break;
1590 }
1591 }
1592 found
1593 };
1594 if locators.is_empty() {
1595 if let Some(rowid) = existing_rowid {
1596 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1597 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1598 }
1599 return Ok(());
1600 }
1601 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1602 match existing_rowid {
1603 Some(rowid) => {
1604 conn.execute(
1605 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1606 params![enc_locators, rowid],
1607 )
1608 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1609 }
1610 None => {
1611 let enc_creator = enc_txt(creator_hex)?;
1612 conn.execute(
1613 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1614 params![community_id, enc_creator, enc_locators],
1615 )
1616 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1617 }
1618 }
1619 Ok(())
1620}
1621
1622pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1625 let conn = super::get_db_connection_guard_static()?;
1626 let mut stmt = conn
1627 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1628 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1629 let rows = stmt
1630 .query_map(params![community_id], |r| {
1631 let creator_hex: String = r.get(0)?;
1632 let json: String = r.get(1)?;
1633 Ok((creator_hex, json))
1634 })
1635 .map_err(|e| format!("query invite-link-sets: {e}"))?;
1636 let mut out = Vec::new();
1637 for row in rows {
1638 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1639 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1640 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1641 }
1642 Ok(out)
1643}
1644
1645pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1649 let conn = super::get_write_connection_guard_static()?;
1650 conn.execute(
1651 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1652 params![pending as i64, community_id],
1653 )
1654 .map_err(|e| format!("set read_cut_pending: {e}"))?;
1655 Ok(())
1656}
1657
1658pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
1666 let conn = super::get_write_connection_guard_static()?;
1667 let changed = conn
1668 .execute(
1669 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
1670 params![community_id],
1671 )
1672 .map_err(|e| format!("set dissolved: {e}"))?;
1673 Ok(changed > 0)
1674}
1675
1676pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
1679 let conn = super::get_db_connection_guard_static()?;
1680 let v: Option<i64> = conn
1681 .query_row(
1682 "SELECT dissolved FROM communities WHERE community_id = ?1",
1683 params![community_id],
1684 |r| r.get(0),
1685 )
1686 .optional()
1687 .map_err(|e| format!("get dissolved: {e}"))?;
1688 Ok(v.unwrap_or(0) != 0)
1689}
1690
1691pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
1694 let conn = super::get_db_connection_guard_static()?;
1695 let v: Option<i64> = conn
1696 .query_row(
1697 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
1698 params![community_id],
1699 |r| r.get(0),
1700 )
1701 .optional()
1702 .map_err(|e| format!("get read_cut_pending: {e}"))?;
1703 Ok(v.unwrap_or(0) != 0)
1704}
1705
1706pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
1711 let conn = super::get_write_connection_guard_static()?;
1712 conn.execute(
1713 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
1714 params![target as i64, community_id],
1715 )
1716 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
1717 Ok(())
1718}
1719
1720pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
1723 let conn = super::get_db_connection_guard_static()?;
1724 let v: Option<i64> = conn
1725 .query_row(
1726 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
1727 params![community_id],
1728 |r| r.get(0),
1729 )
1730 .optional()
1731 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
1732 Ok(v.unwrap_or(0) as u64)
1733}
1734
1735pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
1738 let conn = super::get_db_connection_guard_static()?;
1739 let v: Option<i64> = conn
1740 .query_row(
1741 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
1742 params![community_id, channel_id],
1743 |r| r.get(0),
1744 )
1745 .optional()
1746 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
1747 Ok(v.unwrap_or(0) as u64)
1748}
1749
1750pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
1754 let conn = super::get_write_connection_guard_static()?;
1755 conn.execute(
1756 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
1757 params![server_epoch as i64, community_id, channel_id],
1758 )
1759 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
1760 Ok(())
1761}
1762
1763pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
1765 let conn = super::get_db_connection_guard_static()?;
1766 let mut stmt = conn
1767 .prepare("SELECT community_id FROM communities ORDER BY created_at")
1768 .map_err(|e| e.to_string())?;
1769 let rows = stmt
1770 .query_map([], |r| r.get::<_, String>(0))
1771 .map_err(|e| e.to_string())?;
1772 let mut ids = Vec::new();
1773 for row in rows {
1774 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
1775 }
1776 Ok(ids)
1777}
1778
1779pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
1790 let conn = super::get_db_connection_guard_static()?;
1791 let n: Option<i64> = conn
1792 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
1793 .optional()
1794 .map_err(|e| e.to_string())?;
1795 Ok(n.map(crate::community::ConcordProtocol::from_i64))
1796}
1797
1798pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
1801 let conn = super::get_write_connection_guard_static()?;
1802 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
1803 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
1804 let created = (c.created_at_ms / 1000) as i64;
1805
1806 let enc_root = enc_key(&c.community_root)?;
1807 let enc_name = enc_txt(&c.name)?;
1808 let enc_relays = enc_txt(&relays_json)?;
1809 let enc_desc = enc_txt_opt(&c.description)?;
1810 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
1811 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
1812
1813 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
1814 tx.execute(
1815 "INSERT INTO communities
1816 (community_id, server_root_key, name, relays, created_at, description,
1817 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt)
1818 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10)
1819 ON CONFLICT(community_id) DO UPDATE SET
1820 server_root_key=?2, name=?3, relays=?4, description=?6,
1821 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10",
1822 params![
1823 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
1824 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
1825 ],
1826 )
1827 .map_err(|e| format!("save v2 community: {e}"))?;
1828
1829 for ch in &c.channels {
1830 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1831 let owner_of: Option<String> = tx
1837 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
1838 .optional()
1839 .map_err(|e| format!("channel ownership check: {e}"))?;
1840 if owner_of.is_some_and(|existing| existing != id_hex) {
1841 continue;
1847 }
1848 let stored_key = ch.key.unwrap_or(c.community_root);
1852 let enc_ch_key = enc_key(&stored_key)?;
1853 let enc_ch_name = enc_txt(&ch.name)?;
1854 tx.execute(
1855 "INSERT INTO community_channels
1856 (channel_id, community_id, channel_key, epoch, name, created_at, private)
1857 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1858 ON CONFLICT(channel_id) DO UPDATE SET
1859 channel_key=?3, epoch=?4, name=?5, private=?7",
1860 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64],
1861 )
1862 .map_err(|e| format!("save v2 channel: {e}"))?;
1863 }
1864
1865 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
1870 if keep.is_empty() {
1871 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
1872 .map_err(|e| format!("prune v2 channels: {e}"))?;
1873 } else {
1874 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
1875 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
1876 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
1877 binds.push(id_hex.clone());
1878 binds.extend(keep);
1879 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
1880 .map_err(|e| format!("prune v2 channels: {e}"))?;
1881 }
1882
1883 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
1884 Ok(())
1885}
1886
1887pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
1889 use crate::community::v2::community::{ChannelV2, CommunityV2};
1890 use crate::community::v2::control::CommunityIdentity;
1891 let conn = super::get_db_connection_guard_static()?;
1892 let id_hex = id.to_hex();
1893
1894 let row = conn
1895 .query_row(
1896 "SELECT server_root_key, name, relays, created_at, description,
1897 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt
1898 FROM communities WHERE community_id = ?1",
1899 params![id_hex],
1900 |r| {
1901 Ok((
1902 r.get::<_, Vec<u8>>(0)?,
1903 r.get::<_, String>(1)?,
1904 r.get::<_, String>(2)?,
1905 r.get::<_, i64>(3)?,
1906 r.get::<_, Option<String>>(4)?,
1907 r.get::<_, i64>(5)?,
1908 r.get::<_, i64>(6)?,
1909 r.get::<_, i64>(7)?,
1910 r.get::<_, Option<String>>(8)?,
1911 r.get::<_, Option<String>>(9)?,
1912 ))
1913 },
1914 )
1915 .optional()
1916 .map_err(|e| e.to_string())?;
1917 let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e)) = row
1918 else {
1919 return Ok(None);
1920 };
1921 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
1922 return Ok(None);
1923 }
1924 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
1925 return Err("v2 community row is missing its owner commitment".to_string());
1926 };
1927
1928 let community_root = dec_key(&root_blob)?;
1929 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
1930 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
1931 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
1932 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
1933
1934 let mut channels = Vec::new();
1935 {
1936 let mut stmt = conn
1937 .prepare(
1938 "SELECT channel_id, channel_key, epoch, name, private
1939 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
1940 )
1941 .map_err(|e| e.to_string())?;
1942 let rows = stmt
1943 .query_map(params![id_hex], |r| {
1944 Ok((
1945 r.get::<_, String>(0)?,
1946 r.get::<_, Vec<u8>>(1)?,
1947 r.get::<_, i64>(2)?,
1948 r.get::<_, String>(3)?,
1949 r.get::<_, i64>(4)?,
1950 ))
1951 })
1952 .map_err(|e| e.to_string())?;
1953 for row in rows {
1954 let (ch_hex, key_blob, epoch, name_e, private) = row.map_err(|e| e.to_string())?;
1955 let private = private != 0;
1956 let key = dec_key(&key_blob)?;
1957 channels.push(ChannelV2 {
1958 id: ChannelId(hex_id_to_32(&ch_hex)?),
1959 name: dec_txt(&name_e),
1960 private,
1961 key: (private && key != community_root).then_some(key),
1968 epoch: Epoch(epoch as u64),
1969 });
1970 }
1971 }
1972
1973 Ok(Some(CommunityV2 {
1974 identity,
1975 community_root,
1976 root_epoch: Epoch(root_epoch as u64),
1977 name: dec_txt(&name_e),
1978 description: desc_e.map(|d| dec_txt(&d)),
1979 relays,
1980 channels,
1981 dissolved: dissolved != 0,
1982 created_at_ms: (created as u64).saturating_mul(1000),
1983 }))
1984}
1985
1986fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
1987 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1988 return Err("stored value is not 32-byte hex".to_string());
1989 }
1990 Ok(crate::simd::hex::hex_to_bytes_32(hex))
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::*;
1996
1997 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1998
1999 fn make_test_npub(n: u32) -> String {
2002 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2003 let mut payload = vec![b'q'; 58];
2004 let mut x = n as u64;
2005 let mut i = 58;
2006 while x > 0 && i > 0 {
2007 i -= 1;
2008 payload[i] = BECH32[(x as usize) % 32];
2009 x /= 32;
2010 }
2011 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2012 }
2013
2014 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2015 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2016 crate::db::close_database();
2017 crate::db::clear_id_caches();
2020 let tmp = tempfile::tempdir().unwrap();
2021 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2022 let account = make_test_npub(n);
2023 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2024 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2025 crate::db::set_current_account(account.clone()).unwrap();
2026 crate::db::init_database(&account).unwrap();
2027 (tmp, guard)
2028 }
2029
2030 #[test]
2031 fn edition_head_round_trips_and_upserts() {
2032 let (_tmp, _guard) = init_test_db();
2033 let cid = "f".repeat(64);
2034 let entity = "a".repeat(64);
2035
2036 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2038
2039 let h1 = [0x11u8; 32];
2041 set_edition_head(&cid, &entity, 1, &h1).unwrap();
2042 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2043
2044 let h2 = [0x22u8; 32];
2046 set_edition_head(&cid, &entity, 2, &h2).unwrap();
2047 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2048
2049 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2052 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2053 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2054 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2055
2056 let other = "b".repeat(64);
2058 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2059 }
2060
2061 #[test]
2062 fn server_root_epoch_round_trips() {
2063 let (_tmp, _guard) = init_test_db();
2065 let mut c = Community::create("HQ", "general", vec![]);
2066 save_community(&c).unwrap();
2067 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2068
2069 c.server_root_epoch = Epoch(5);
2070 c.server_root_key = ServerRootKey([0x42u8; 32]);
2071 save_community(&c).unwrap();
2072 let loaded = load_community(&c.id).unwrap().unwrap();
2073 assert_eq!(loaded.server_root_epoch, Epoch(5));
2074 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2075 }
2076
2077 #[test]
2078 fn epoch_key_archive_retains_every_epoch() {
2079 let (_tmp, _guard) = init_test_db();
2082 let cid = "f".repeat(64);
2083 let scope = "a".repeat(64);
2084
2085 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2086 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2087 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2088
2089 let held = held_epoch_keys(&cid, &scope).unwrap();
2090 assert_eq!(held.len(), 3, "all three epoch keys retained");
2091 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2092 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2093 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2094
2095 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2097 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2098
2099 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2101 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2102 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2103
2104 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2107 assert_eq!(
2108 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2109 None,
2110 "epoch 1 under a different scope is not the channel's key"
2111 );
2112 }
2113
2114 #[test]
2115 fn save_community_populates_the_epoch_archive() {
2116 let (_tmp, _guard) = init_test_db();
2119 let c = Community::create("HQ", "general", vec![]);
2120 save_community(&c).unwrap();
2121 let cid = c.id.to_hex();
2122
2123 assert_eq!(
2125 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2126 Some(c.server_root_key.as_bytes())
2127 );
2128 let chan = &c.channels[0];
2130 assert_eq!(
2131 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2132 Some(chan.key.as_bytes())
2133 );
2134 }
2135
2136 #[test]
2137 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2138 let (_tmp, _guard) = init_test_db();
2139 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2142 crate::state::set_encryption_enabled(true);
2143
2144 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2145 c.server_root_key = ServerRootKey([0x42u8; 32]);
2146 c.description = Some("top secret".into());
2147 save_community(&c).unwrap();
2148 let cid = c.id.to_hex();
2149 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2150
2151 {
2154 let conn = crate::db::get_db_connection_guard_static().unwrap();
2155 let (root_len, name, banlist): (i64, String, String) = conn
2156 .query_row(
2157 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2158 params![cid],
2159 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2160 )
2161 .unwrap();
2162 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2163 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2164 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2165 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2166 let key_len: i64 = conn
2167 .query_row(
2168 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2169 params![cid],
2170 |r| r.get(0),
2171 )
2172 .unwrap();
2173 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2174 }
2175
2176 let loaded = load_community(&c.id).unwrap().unwrap();
2178 assert_eq!(loaded.name, "Secret HQ");
2179 assert_eq!(loaded.description.as_deref(), Some("top secret"));
2180 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2181 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2182 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2183
2184 crate::state::set_encryption_enabled(false);
2185 crate::state::ENCRYPTION_KEY.clear(&[]);
2186 }
2187
2188 #[test]
2189 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2190 let (_tmp, _guard) = init_test_db();
2193 crate::state::set_encryption_enabled(false);
2194 let mut c = Community::create("Legacy HQ", "general", vec![]);
2195 c.server_root_key = ServerRootKey([0x42u8; 32]);
2196 save_community(&c).unwrap();
2197
2198 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2199 crate::state::set_encryption_enabled(true);
2200 let loaded = load_community(&c.id).unwrap().unwrap();
2201 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2202 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2203
2204 crate::state::set_encryption_enabled(false);
2205 crate::state::ENCRYPTION_KEY.clear(&[]);
2206 }
2207
2208 #[test]
2209 fn save_and_load_round_trip() {
2210 let (_tmp, _guard) = init_test_db();
2211 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2212 save_community(&original).unwrap();
2213
2214 let loaded = load_community(&original.id).unwrap().expect("present");
2215 assert_eq!(loaded.id, original.id);
2216 assert_eq!(loaded.name, "Vector HQ");
2217 assert_eq!(loaded.relays, original.relays);
2218 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2220 assert_eq!(loaded.channels.len(), 1);
2222 assert_eq!(loaded.channels[0].id, original.channels[0].id);
2223 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2224 assert_eq!(loaded.channels[0].epoch, Epoch(0));
2225 assert_eq!(loaded.channels[0].name, "general");
2226 }
2227
2228 #[test]
2229 fn owner_is_protected_from_the_banlist_a_member_is_not() {
2230 use nostr_sdk::JsonUtil;
2231 let (_tmp, _guard) = init_test_db();
2232 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2233 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2235 community.owner_attestation = Some(
2236 crate::community::owner::build_owner_attestation_unsigned(
2237 owner_id.public_key(),
2238 &community.id.to_hex(),
2239 )
2240 .sign_with_keys(&owner_id)
2241 .unwrap()
2242 .as_json(),
2243 );
2244 save_community(&community).unwrap();
2245
2246 let member = Keys::generate();
2248 set_community_banlist(
2249 &community.id.to_hex(),
2250 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2251 1,
2252 )
2253 .unwrap();
2254
2255 let loaded = load_community(&community.id).unwrap().unwrap();
2256 let ch = &loaded.channels[0];
2257 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2259 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2260 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2262 }
2263
2264 #[test]
2265 fn loaded_keys_actually_decrypt() {
2266 let (_tmp, _guard) = init_test_db();
2269 let original = Community::create("HQ", "general", vec![]);
2270 save_community(&original).unwrap();
2271 let loaded = load_community(&original.id).unwrap().unwrap();
2272
2273 let author = nostr_sdk::prelude::Keys::generate();
2274 let chan = &original.channels[0];
2275 let sealed = crate::community::envelope::seal_message(
2276 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2277 )
2278 .unwrap();
2279 let opened = crate::community::envelope::open_message(
2280 &sealed,
2281 &loaded.channels[0].key,
2282 &loaded.channels[0].id,
2283 loaded.channels[0].epoch,
2284 )
2285 .unwrap();
2286 assert_eq!(opened.content, "persisted!");
2287 }
2288
2289 #[test]
2290 fn member_view_round_trips() {
2291 let (_tmp, _guard) = init_test_db();
2294 let member = Community {
2295 id: CommunityId([7u8; 32]),
2296 server_root_key: ServerRootKey([8u8; 32]),
2297 server_root_epoch: Epoch(0),
2298 name: "Joined".into(),
2299 description: None,
2300 icon: None,
2301 banner: None,
2302 relays: vec!["wss://r".into()],
2303 channels: vec![Channel {
2304 id: ChannelId([9u8; 32]),
2305 key: ChannelKey([10u8; 32]),
2306 epoch: Epoch(0),
2307 name: "general".into(),
2308 banned: Vec::new(),
2309 protected: Vec::new(), roster: Default::default(),
2310 epoch_keys: Vec::new(),
2311 dissolved: false,
2312 }],
2313 owner_attestation: None,
2314 dissolved: false,
2315 };
2316 save_community(&member).unwrap();
2317 let loaded = load_community(&member.id).unwrap().expect("present");
2318 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
2319 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
2320 }
2321
2322 #[test]
2323 fn large_epoch_round_trips_losslessly() {
2324 let (_tmp, _guard) = init_test_db();
2326 let mut c = Community::create("HQ", "g", vec![]);
2327 c.channels[0].epoch = Epoch(u64::MAX - 7);
2328 save_community(&c).unwrap();
2329 let loaded = load_community(&c.id).unwrap().unwrap();
2330 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
2331 }
2332
2333 #[test]
2334 fn malformed_channel_id_row_errors_not_corrupts() {
2335 let (_tmp, _guard) = init_test_db();
2338 let c = Community::create("HQ", "g", vec![]);
2339 save_community(&c).unwrap();
2340 {
2341 let conn = crate::db::get_write_connection_guard_static().unwrap();
2342 conn.execute(
2343 "INSERT OR REPLACE INTO community_channels
2344 (channel_id, community_id, channel_key, epoch, name, created_at)
2345 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
2346 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2347 )
2348 .unwrap();
2349 }
2350 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2351 }
2352
2353 #[test]
2354 fn message_key_store_take_round_trip() {
2355 let (_tmp, _guard) = init_test_db();
2356 let eph = Keys::generate();
2357 let relays = vec!["wss://r.one".to_string()];
2358 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2360
2361 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2362 assert_eq!(
2363 loaded.secret_key().as_secret_bytes(),
2364 eph.secret_key().as_secret_bytes()
2365 );
2366 assert_eq!(outer, "outer_evid");
2367 assert_eq!(r, relays);
2368 assert!(take_message_key("inner_msg_id").unwrap().is_none());
2370 }
2371
2372 #[test]
2373 fn missing_community_is_none() {
2374 let (_tmp, _guard) = init_test_db();
2375 let absent = CommunityId([0x33u8; 32]);
2376 assert!(load_community(&absent).unwrap().is_none());
2377 }
2378
2379 #[test]
2380 fn list_ids_reflects_saved() {
2381 let (_tmp, _guard) = init_test_db();
2382 let a = Community::create("A", "g", vec![]);
2383 let b = Community::create("B", "g", vec![]);
2384 save_community(&a).unwrap();
2385 save_community(&b).unwrap();
2386 let ids = list_community_ids().unwrap();
2387 assert_eq!(ids.len(), 2);
2388 assert!(ids.contains(&a.id) && ids.contains(&b.id));
2389 }
2390
2391 #[test]
2392 fn delete_community_clears_all_local_state() {
2393 let (_tmp, _guard) = init_test_db();
2394 let c = Community::create("HQ", "general", vec!["r1".into()]);
2395 save_community(&c).unwrap();
2396 let cid = c.id.to_hex();
2397 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2398 save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2399 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2400
2401 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2403
2404 delete_community(&cid).unwrap();
2405 assert!(!community_exists(&c.id).unwrap());
2406 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2407 assert!(list_public_invites(&cid).unwrap().is_empty());
2408 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2409 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2410 }
2411
2412 #[test]
2413 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2414 let (_tmp, _guard) = init_test_db();
2417 let c = Community::create("HQ", "general", vec!["r1".into()]);
2418 save_community(&c).unwrap();
2419 let cid = c.id.to_hex();
2420 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2421 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2422
2423 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2424 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2425 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2426
2427 delete_community_retain_keys(&cid).unwrap();
2428
2429 assert!(!community_exists(&c.id).unwrap());
2431 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2432 assert!(list_public_invites(&cid).unwrap().is_empty());
2433 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2434 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2436 "base epoch keys retained for self-scrub");
2437 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2438 "channel epoch keys retained for self-scrub");
2439 }
2440
2441 #[test]
2442 fn channel_resolves_to_owning_community() {
2443 let (_tmp, _guard) = init_test_db();
2444 let c = Community::create("HQ", "general", vec![]);
2445 save_community(&c).unwrap();
2446 let chan = c.channels[0].id.to_hex();
2447 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2448 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2449 }
2450
2451 #[test]
2452 fn community_exists_reflects_saved() {
2453 let (_tmp, _guard) = init_test_db();
2454 let c = Community::create("A", "g", vec![]);
2455 assert!(!community_exists(&c.id).unwrap());
2456 save_community(&c).unwrap();
2457 assert!(community_exists(&c.id).unwrap());
2458 }
2459
2460 #[test]
2461 fn pending_invite_first_wins_and_round_trips() {
2462 let (_tmp, _guard) = init_test_db();
2463 let cid = "ab".repeat(32);
2464 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2467 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2468 assert!(pending_invite_exists(&cid).unwrap());
2469
2470 let listed = list_pending_invites().unwrap();
2471 assert_eq!(listed.len(), 1);
2472 assert_eq!(listed[0].community_id, cid);
2473 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2474 assert_eq!(listed[0].inviter_npub, "npub1inviter");
2475
2476 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2478 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2479 delete_pending_invite(&cid).unwrap();
2480 assert!(!pending_invite_exists(&cid).unwrap());
2481 assert!(get_pending_invite(&cid).unwrap().is_none());
2482 }
2483
2484 #[test]
2485 fn purge_drops_invites_for_held_communities_only() {
2486 let (_tmp, _guard) = init_test_db();
2487 let held = Community::create("Held", "general", vec![]);
2490 save_community(&held).unwrap();
2491 let held_hex = held.id.to_hex();
2492 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2493 let stranger = "ab".repeat(32);
2495 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2496
2497 let n = purge_pending_invites_for_held_communities().unwrap();
2498 assert_eq!(n, 1, "only the held community's invite is purged");
2499 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2500 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2501 }
2502
2503 #[test]
2504 fn decline_drops_pending_invite() {
2505 let (_tmp, _guard) = init_test_db();
2506 let cid = "cd".repeat(32);
2507 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2508 delete_pending_invite(&cid).unwrap();
2509 assert!(!pending_invite_exists(&cid).unwrap());
2510 }
2511
2512 #[test]
2513 fn pending_invites_are_capped_keeping_the_newest() {
2514 let (_tmp, _guard) = init_test_db();
2515 for i in 0..150u32 {
2520 let cid = format!("{:064x}", i);
2521 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2522 }
2523 let all = list_pending_invites().unwrap();
2524 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2525 for i in 150..400u32 {
2527 let cid = format!("{:064x}", i);
2528 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2529 }
2530 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2531 }
2532}