use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
use nostr_sdk::ToBech32;
use rusqlite::{params, OptionalExtension};
use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> {
bytes
.try_into()
.map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len()))
}
fn enc_key(k: &[u8; 32]) -> Result<Vec<u8>, String> { crate::crypto::maybe_encrypt_blob(k) }
fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) }
fn enc_txt(s: &str) -> Result<String, String> { crate::crypto::maybe_encrypt_text(s) }
fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) }
fn enc_txt_opt(s: &Option<String>) -> Result<Option<String>, String> {
s.as_deref().map(enc_txt).transpose()
}
pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> {
crate::simd::hex::hex_to_bytes_32_checked(hex)
.ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len()))
}
pub fn save_community(community: &Community) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?;
let community_id = community.id.to_hex();
let icon_json = community
.icon
.as_ref()
.map(|i| serde_json::to_string(i))
.transpose()
.map_err(|e| e.to_string())?;
let banner_json = community
.banner
.as_ref()
.map(|b| serde_json::to_string(b))
.transpose()
.map_err(|e| e.to_string())?;
let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?;
let enc_root = enc_key(community.server_root_key.as_bytes())?;
let enc_name = enc_txt(&community.name)?;
let enc_relays = enc_txt(&relays_json)?;
let enc_desc = enc_txt_opt(&community.description)?;
let enc_icon = enc_txt_opt(&icon_json)?;
let enc_banner = enc_txt_opt(&banner_json)?;
let enc_owner = enc_txt_opt(&community.owner_attestation)?;
tx.execute(
"INSERT INTO communities
(community_id, server_root_key, name, relays, created_at,
description, icon, banner, owner_attestation, server_root_epoch)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(community_id) DO UPDATE SET
server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays,
description=excluded.description, icon=excluded.icon, banner=excluded.banner,
owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch",
params![
community_id,
&enc_root[..],
enc_name,
enc_relays,
now_secs(),
enc_desc,
enc_icon,
enc_banner,
enc_owner,
community.server_root_epoch.0 as i64,
],
)
.map_err(|e| format!("save community: {e}"))?;
store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
community.server_root_epoch.0, community.server_root_key.as_bytes())?;
for channel in &community.channels {
let enc_chan_key = enc_key(channel.key.as_bytes())?;
let enc_chan_name = enc_txt(&channel.name)?;
tx.execute(
"INSERT INTO community_channels
(channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(channel_id) DO UPDATE SET
community_id=excluded.community_id, channel_key=excluded.channel_key,
epoch=excluded.epoch, name=excluded.name",
params![
channel.id.to_hex(),
community_id,
&enc_chan_key[..],
channel.epoch.0 as i64,
enc_chan_name,
now_secs(),
community.server_root_epoch.0 as i64,
],
)
.map_err(|e| format!("save channel: {e}"))?;
store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?;
}
tx.commit().map_err(|e| format!("save community commit: {e}"))?;
Ok(())
}
pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
}
fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
conn: &C,
community_id: &str,
scope_id: &str,
epoch: u64,
key: &[u8; 32],
) -> Result<(), String> {
let enc = enc_key(key)?;
conn.execute(
"INSERT OR REPLACE INTO community_epoch_keys
(community_id, scope_id, epoch, key, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
)
.map_err(|e| format!("store epoch key: {e}"))?;
Ok(())
}
pub fn advance_channel_epoch(
community_id: &str,
channel_id: &str,
new_epoch: u64,
new_key: &[u8; 32],
) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
let cur: Option<i64> = tx
.query_row(
"SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
params![community_id, channel_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("read channel head: {e}"))?;
let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
if advanced {
let enc = enc_key(new_key)?;
tx.execute(
"UPDATE community_channels SET epoch = ?1, channel_key = ?2
WHERE community_id = ?3 AND channel_id = ?4",
params![new_epoch as i64, &enc[..], community_id, channel_id],
)
.map_err(|e| format!("advance channel head: {e}"))?;
}
tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
Ok(advanced)
}
pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
let cur: Option<i64> = tx
.query_row(
"SELECT server_root_epoch FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("read server-root epoch: {e}"))?;
let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
if advanced {
let enc = enc_key(new_root)?;
tx.execute(
"UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
params![new_epoch as i64, &enc[..], community_id],
)
.map_err(|e| format!("advance server-root head: {e}"))?;
}
tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
Ok(advanced)
}
pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
let enc = enc_key(new_root)?;
let switched = tx
.execute(
"UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
params![&enc[..], community_id, epoch as i64],
)
.map_err(|e| format!("converge server-root head: {e}"))?
> 0;
tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
Ok(switched)
}
pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
let enc = enc_key(new_key)?;
let switched = tx
.execute(
"UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
params![&enc[..], community_id, channel_id, epoch as i64],
)
.map_err(|e| format!("converge channel head: {e}"))?
> 0;
tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
Ok(switched)
}
pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id, scope_id], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
})
.map_err(|e| e.to_string())?;
let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
for row in rows {
let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
}
out.sort_by_key(|(e, _)| e.0);
Ok(out)
}
pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
let conn = super::get_db_connection_guard_static()?;
let blob: Option<Vec<u8>> = conn
.query_row(
"SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
params![community_id, scope_id, epoch as i64],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("held epoch key: {e}"))?;
blob.map(|b| dec_key(&b)).transpose()
}
pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
let conn = super::get_db_connection_guard_static().ok()?;
conn.query_row(
"SELECT created_at FROM communities WHERE community_id = ?1",
params![id.to_hex()],
|r| r.get::<_, i64>(0),
)
.optional()
.ok()
.flatten()
.map(|secs| (secs.max(0) as u64) * 1000)
}
pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
let conn = super::get_db_connection_guard_static()?;
let id_hex = id.to_hex();
let row = conn
.query_row(
"SELECT server_root_key, name, relays,
description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
FROM communities WHERE community_id = ?1",
params![id_hex],
|r| {
Ok((
r.get::<_, Vec<u8>>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<String>>(5)?,
r.get::<_, String>(6)?,
r.get::<_, Option<String>>(7)?,
r.get::<_, i64>(8)?,
r.get::<_, i64>(9)?,
))
},
)
.optional()
.map_err(|e| format!("load community: {e}"))?;
let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
match row {
Some(t) => t,
None => return Ok(None),
};
let dissolved = dissolved_int != 0;
let name = dec_txt(&name);
let relays_json = dec_txt(&relays_json);
let description = description.map(|s| dec_txt(&s));
let icon_json = icon_json.map(|s| dec_txt(&s));
let banner_json = banner_json.map(|s| dec_txt(&s));
let banlist_json = dec_txt(&banlist_json);
let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
.unwrap_or_default()
.iter()
.filter_map(|h| PublicKey::from_hex(h).ok())
.collect();
let icon = icon_json
.map(|j| serde_json::from_str(&j))
.transpose()
.map_err(|e| format!("icon json: {e}"))?;
let banner = banner_json
.map(|j| serde_json::from_str(&j))
.transpose()
.map_err(|e| format!("banner json: {e}"))?;
let server_root_key = ServerRootKey(dec_key(&root_blob)?);
let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
let mut protected: Vec<PublicKey> = Vec::new();
if let Some(owner) = owner_attestation
.as_ref()
.and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
{
protected.push(owner);
}
let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
let mut stmt = conn
.prepare(
"SELECT channel_id, channel_key, epoch, name
FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![id_hex], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Vec<u8>>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
};
let roster = get_community_roles(&id_hex).unwrap_or_default();
let mut channels = Vec::new();
for (cid_hex, key_blob, epoch, cname) in raw_channels {
let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
let mut ek_stmt = conn
.prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
.map_err(|e| e.to_string())?;
let rows = ek_stmt
.query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for row in rows {
let (e, blob) = row.map_err(|e| e.to_string())?;
if let Ok(k) = dec_key(&blob) {
out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
}
}
out
};
channels.push(Channel {
id: ChannelId(hex_id_to_32(&cid_hex)?),
key: ChannelKey(dec_key(&key_blob)?),
epoch: Epoch(epoch as u64),
name: dec_txt(&cname),
banned: banned.clone(),
protected: protected.clone(),
roster: roster.clone(),
epoch_keys,
dissolved,
});
}
Ok(Some(Community {
id: *id,
server_root_key,
server_root_epoch: Epoch(server_root_epoch as u64),
name,
description,
icon,
banner,
relays,
channels,
owner_attestation,
dissolved,
}))
}
pub fn store_message_key(
message_id: &str,
outer_event_id: &str,
ephemeral: &Keys,
relays: &[String],
) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
let enc_secret = enc_key(&sk_bytes)?;
let enc_relays = enc_txt(&relays_json)?;
conn.execute(
"INSERT OR REPLACE INTO community_message_keys
(outer_event_id, message_id, ephemeral_secret, relays, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
outer_event_id,
message_id,
&enc_secret[..],
enc_relays,
now_secs(),
],
)
.map_err(|e| format!("store message key: {e}"))?;
Ok(())
}
pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
let conn = super::get_db_connection_guard_static()?;
let row = conn
.query_row(
"SELECT ephemeral_secret, outer_event_id, relays
FROM community_message_keys WHERE message_id = ?1",
params![message_id],
|r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
)
.optional()
.map_err(|e| format!("get message key: {e}"))?;
let (secret_blob, outer_event_id, relays_json) = match row {
Some(t) => t,
None => return Ok(None),
};
let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
Ok(Some((Keys::new(secret), outer_event_id, relays)))
}
pub fn delete_message_key(message_id: &str) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"DELETE FROM community_message_keys WHERE message_id = ?1",
params![message_id],
)
.map_err(|e| format!("remove message key: {e}"))?;
Ok(())
}
pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
let r = get_message_key(message_id)?;
if r.is_some() {
delete_message_key(message_id)?;
}
Ok(r)
}
pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
let conn = super::get_db_connection_guard_static()?;
conn.query_row(
"SELECT community_id FROM community_channels WHERE channel_id = ?1",
params![channel_id],
|r| r.get::<_, String>(0),
)
.optional()
.map_err(|e| format!("community_id_for_channel: {e}"))
}
pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
let conn = super::get_db_connection_guard_static()?;
let found: Option<i64> = conn
.query_row(
"SELECT 1 FROM communities WHERE community_id = ?1",
params![id.to_hex()],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("community_exists: {e}"))?;
Ok(found.is_some())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PendingCommunityInvite {
pub community_id: String,
pub bundle_json: String,
pub inviter_npub: String,
pub received_at: i64,
}
pub fn save_pending_invite(
community_id: &str,
bundle_json: &str,
inviter_npub: &str,
) -> Result<bool, String> {
const MAX_PENDING_INVITES: usize = 100;
let conn = super::get_write_connection_guard_static()?;
let enc_bundle = enc_txt(bundle_json)?;
let enc_inviter = enc_txt(inviter_npub)?;
let changed = conn
.execute(
"INSERT OR IGNORE INTO pending_community_invites
(community_id, bundle_json, inviter_npub, received_at)
VALUES (?1, ?2, ?3, ?4)",
params![community_id, enc_bundle, enc_inviter, now_secs()],
)
.map_err(|e| format!("save pending invite: {e}"))?;
if changed > 0 {
let _ = conn.execute(
"DELETE FROM pending_community_invites
WHERE community_id IN (
SELECT community_id FROM pending_community_invites
ORDER BY received_at DESC, community_id DESC
LIMIT -1 OFFSET ?1
)",
params![MAX_PENDING_INVITES],
);
}
Ok(changed > 0)
}
pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
let conn = super::get_write_connection_guard_static()?;
let n = conn
.execute(
"DELETE FROM pending_community_invites
WHERE community_id IN (SELECT community_id FROM communities)",
[],
)
.map_err(|e| format!("purge held pending invites: {e}"))?;
Ok(n)
}
pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare(
"SELECT community_id, bundle_json, inviter_npub, received_at
FROM pending_community_invites ORDER BY received_at DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok(PendingCommunityInvite {
community_id: r.get(0)?,
bundle_json: dec_txt(&r.get::<_, String>(1)?),
inviter_npub: dec_txt(&r.get::<_, String>(2)?),
received_at: r.get(3)?,
})
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(|e| e.to_string())?);
}
Ok(out)
}
pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
let conn = super::get_db_connection_guard_static()?;
let raw: Option<String> = conn
.query_row(
"SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
params![community_id],
|r| r.get::<_, String>(0),
)
.optional()
.map_err(|e| format!("get pending invite: {e}"))?;
Ok(raw.map(|s| dec_txt(&s)))
}
pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"DELETE FROM pending_community_invites WHERE community_id = ?1",
params![community_id],
)
.map_err(|e| format!("delete pending invite: {e}"))?;
Ok(())
}
pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
let conn = super::get_db_connection_guard_static()?;
let found: Option<i64> = conn
.query_row(
"SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("pending_invite_exists: {e}"))?;
Ok(found.is_some())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PublicInviteRecord {
pub token: String,
pub community_id: String,
pub url: String,
pub expires_at: Option<i64>,
pub created_at: i64,
pub label: Option<String>,
#[serde(default)]
pub join_count: u64,
}
pub fn save_public_invite(
token: &str,
community_id: &str,
url: &str,
expires_at: Option<i64>,
label: Option<&str>,
) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let enc_token = enc_txt(token)?;
let enc_url = enc_txt(url)?;
let enc_label = label.map(enc_txt).transpose()?;
conn.execute(
"INSERT OR REPLACE INTO community_public_invites
(token, community_id, url, expires_at, created_at, label)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
)
.map_err(|e| format!("save public invite: {e}"))?;
Ok(())
}
pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare(
"SELECT token, community_id, url, expires_at, created_at, label
FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| {
Ok(PublicInviteRecord {
token: dec_txt(&r.get::<_, String>(0)?),
community_id: r.get(1)?,
url: dec_txt(&r.get::<_, String>(2)?),
expires_at: r.get(3)?,
created_at: r.get(4)?,
label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
join_count: 0,
})
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(|e| e.to_string())?);
}
if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
if let Ok(counts) = community_invite_join_counts(community_id, &me) {
for rec in &mut out {
if let Some(l) = rec.label.as_deref() {
rec.join_count = counts.get(l).copied().unwrap_or(0);
}
}
}
}
Ok(out)
}
pub fn delete_public_invite(token: &str) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let rows: Vec<(i64, String)> = {
let mut stmt = conn
.prepare("SELECT rowid, token FROM community_public_invites")
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
mapped.filter_map(|r| r.ok()).collect()
};
for (rowid, stored) in rows {
if dec_txt(&stored) == token {
conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
.map_err(|e| format!("delete public invite: {e}"))?;
}
}
Ok(())
}
pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare(
"SELECT token, community_id, url, expires_at, created_at, label
FROM community_public_invites ORDER BY created_at DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok(PublicInviteRecord {
token: dec_txt(&r.get::<_, String>(0)?),
community_id: r.get(1)?,
url: dec_txt(&r.get::<_, String>(2)?),
expires_at: r.get(3)?,
created_at: r.get(4)?,
label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
join_count: 0,
})
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(|e| e.to_string())?);
}
Ok(out)
}
pub fn upsert_public_invite(
token: &str,
community_id: &str,
url: &str,
expires_at: Option<i64>,
created_at: i64,
label: Option<&str>,
) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let already = {
let mut stmt = conn
.prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let stored: Vec<String> = stmt
.query_map(params![community_id], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
stored.iter().any(|s| dec_txt(s) == token)
};
if already {
return Ok(false);
}
let enc_token = enc_txt(token)?;
let enc_url = enc_txt(url)?;
let enc_label = label.map(enc_txt).transpose()?;
conn.execute(
"INSERT INTO community_public_invites
(token, community_id, url, expires_at, created_at, label)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
)
.map_err(|e| format!("upsert public invite: {e}"))?;
Ok(true)
}
pub fn delete_community(community_id: &str) -> Result<(), String> {
delete_community_inner(community_id, false)
}
pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
delete_community_inner(community_id, true)
}
fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
for sql in [
Some("DELETE FROM communities WHERE community_id = ?1"),
Some("DELETE FROM community_channels WHERE community_id = ?1"),
(!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
]
.into_iter()
.flatten()
{
tx.execute(sql, params![community_id])
.map_err(|e| format!("delete community: {e}"))?;
}
tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
Ok(())
}
pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
const COMMUNITY_MEMBER_CAP: usize = 500;
use std::collections::HashMap;
let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
Some(c) => c,
None => return Ok(Vec::new()),
};
let owner_b32: Option<String> = community
.owner_attestation
.as_deref()
.and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
.and_then(|pk| pk.to_bech32().ok());
let mut chat_ints: Vec<i64> = Vec::new();
for ch in &community.channels {
if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
chat_ints.push(cid);
}
}
let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
let mut active: HashMap<String, u64> = HashMap::new();
let mut left: HashMap<String, u64> = HashMap::new();
if !chat_ints.is_empty() {
let conn = super::get_db_connection_guard_static()?;
let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
{
let sql = format!(
"SELECT npub, MAX(created_at) FROM events \
WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
GROUP BY npub"
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})
.map_err(|e| e.to_string())?;
for row in rows {
let (npub, at) = row.map_err(|e| e.to_string())?;
active.insert(npub, at);
}
}
{
let sql = format!(
"SELECT npub, created_at, tags FROM events \
WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
})
.map_err(|e| e.to_string())?;
for row in rows {
let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
.ok()
.and_then(|tags| {
tags.into_iter()
.find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
.and_then(|t| t.into_iter().nth(1))
});
match etype.as_deref() {
Some("1") => {
let e = active.entry(npub).or_insert(0);
if at > *e { *e = at; }
}
Some("0") => {
let e = left.entry(npub).or_insert(0);
if at > *e { *e = at; }
}
_ => {}
}
}
}
}
let banned: std::collections::HashSet<String> = community
.channels
.first()
.map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
.unwrap_or_default();
let mut out: Vec<(String, u64)> = active
.into_iter()
.filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
.collect();
{
let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
let mut reassert = |npub: String| {
if !banned.contains(&npub) && present.insert(npub.clone()) {
out.push((npub, now_secs() as u64));
}
};
if let Some(o) = owner_b32 {
reassert(o);
}
if let Ok(roles) = get_community_roles(community_id) {
for g in &roles.grants {
if g.role_ids.is_empty() {
continue; }
if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
reassert(b32);
}
}
}
}
out.sort_by(|a, b| b.1.cmp(&a.1));
out.truncate(COMMUNITY_MEMBER_CAP);
Ok(out)
}
pub fn community_invite_join_counts(
community_id: &str,
inviter_npub: &str,
) -> Result<std::collections::HashMap<String, u64>, String> {
use std::collections::{HashMap, HashSet};
let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
Some(c) => c,
None => return Ok(HashMap::new()),
};
let mut chat_ints: Vec<i64> = Vec::new();
for ch in &community.channels {
if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
chat_ints.push(cid);
}
}
if chat_ints.is_empty() {
return Ok(HashMap::new());
}
let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
let conn = super::get_db_connection_guard_static()?;
let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT npub, tags FROM events \
WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.map_err(|e| e.to_string())?;
let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
for row in rows {
let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
Ok(t) => t,
Err(_) => continue,
};
let tag_val = |key: &str| -> Option<String> {
tags.iter()
.find(|t| t.first().map(|s| s == key).unwrap_or(false))
.and_then(|t| t.get(1).cloned())
};
if tag_val("event-type").as_deref() != Some("1") {
continue;
}
if tag_val("invited-by").as_deref() != Some(inviter_npub) {
continue;
}
if let Some(label) = tag_val("invited-label") {
per_label.entry(label).or_default().insert(joiner);
}
}
Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
}
pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
params![json, at, community_id],
)
.map_err(|e| format!("set banlist: {e}"))?;
Ok(())
}
pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
let conn = super::get_db_connection_guard_static()?;
let at: Option<i64> = conn
.query_row(
"SELECT banlist_at FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get banlist_at: {e}"))?;
Ok(at.unwrap_or(0))
}
pub fn set_community_roles(
community_id: &str,
roles: &crate::community::roles::CommunityRoles,
at: i64,
) -> Result<(), String> {
let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
params![json, at, community_id],
)
.map_err(|e| format!("set roles: {e}"))?;
Ok(())
}
pub fn get_community_roles(
community_id: &str,
) -> Result<crate::community::roles::CommunityRoles, String> {
let conn = super::get_db_connection_guard_static()?;
let json: Option<String> = conn
.query_row(
"SELECT roles FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get roles: {e}"))?;
Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
}
pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
let conn = super::get_db_connection_guard_static()?;
let at: Option<i64> = conn
.query_row(
"SELECT roles_at FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get roles_at: {e}"))?;
Ok(at.unwrap_or(0))
}
pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
}
pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
}
pub 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> {
set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
}
fn 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> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
ON CONFLICT(community_id, entity_id) DO UPDATE SET
version = excluded.version,
self_hash = excluded.self_hash,
inner_id = excluded.inner_id,
epoch = excluded.epoch
WHERE excluded.epoch > community_edition_heads.epoch
OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
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)],
)
.map_err(|e| format!("set edition head: {e}"))?;
Ok(())
}
pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
}
pub 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> {
converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
}
fn 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> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE community_edition_heads
SET self_hash = ?4, inner_id = ?5
WHERE community_id = ?1 AND entity_id = ?2
AND version = ?3
AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
AND (inner_id IS NULL OR ?5 < inner_id)",
params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
)
.map_err(|e| format!("converge edition head: {e}"))?;
Ok(())
}
pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
let conn = super::get_db_connection_guard_static()?;
let row: Option<Option<Vec<u8>>> = conn
.query_row(
"SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
params![community_id, entity_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get edition head inner_id: {e}"))?;
match row.flatten() {
Some(blob) if blob.len() == 32 => {
let mut h = [0u8; 32];
h.copy_from_slice(&blob);
Ok(Some(h))
}
_ => Ok(None),
}
}
pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
let conn = super::get_db_connection_guard_static()?;
let row: Option<(i64, Vec<u8>)> = conn
.query_row(
"SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
params![community_id, entity_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(|e| format!("get edition head: {e}"))?;
match row {
Some((v, hash)) if hash.len() == 32 => {
let mut h = [0u8; 32];
h.copy_from_slice(&hash);
Ok(Some((v as u64, h)))
}
_ => Ok(None),
}
}
pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let mut out = std::collections::HashSet::new();
for row in rows {
out.insert(row.map_err(|e| e.to_string())?);
}
Ok(out)
}
pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
})
.map_err(|e| e.to_string())?;
let mut out = std::collections::HashMap::new();
for row in rows {
let (entity, version, hash) = row.map_err(|e| e.to_string())?;
if hash.len() == 32 {
let mut h = [0u8; 32];
h.copy_from_slice(&hash);
out.insert(entity, (version as u64, h));
}
}
Ok(out)
}
pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
})
.map_err(|e| e.to_string())?;
let mut out = std::collections::HashMap::new();
for row in rows {
let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
if hash.len() == 32 {
let mut h = [0u8; 32];
h.copy_from_slice(&hash);
let inner_id = inner.and_then(|b| {
(b.len() == 32).then(|| {
let mut i = [0u8; 32];
i.copy_from_slice(&b);
i
})
});
out.insert(entity, (epoch as u64, version as u64, h, inner_id));
}
}
Ok(out)
}
pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
})
.map_err(|e| e.to_string())?;
let mut out = std::collections::HashMap::new();
for row in rows {
let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
if hash.len() == 32 {
let mut h = [0u8; 32];
h.copy_from_slice(&hash);
out.insert(entity, (epoch as u64, version as u64, h));
}
}
Ok(out)
}
pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
let conn = super::get_db_connection_guard_static()?;
let json: Option<String> = conn
.query_row(
"SELECT banlist FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get banlist: {e}"))?;
Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
}
pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
params![json, community_id],
)
.map_err(|e| format!("set invite registry: {e}"))?;
Ok(())
}
pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
let conn = super::get_db_connection_guard_static()?;
let json: Option<String> = conn
.query_row(
"SELECT invite_registry FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get invite registry: {e}"))?;
Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
}
pub struct InviteLinkSetRow {
pub creator_hex: String,
pub locators: Vec<String>,
}
pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
let mut conn = super::get_write_connection_guard_static()?;
let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
.map_err(|e| format!("clear invite-link-sets: {e}"))?;
for s in sets {
if s.locators.is_empty() {
continue; }
let enc_creator = enc_txt(&s.creator_hex)?;
let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
tx.execute(
"INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
params![community_id, enc_creator, enc_locators],
)
.map_err(|e| format!("insert invite-link-set: {e}"))?;
}
tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
Ok(())
}
pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let existing_rowid: Option<i64> = {
let mut stmt = conn
.prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
let mut found = None;
for row in rows {
let (rowid, stored) = row.map_err(|e| e.to_string())?;
if dec_txt(&stored) == creator_hex {
found = Some(rowid);
break;
}
}
found
};
if locators.is_empty() {
if let Some(rowid) = existing_rowid {
conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
.map_err(|e| format!("delete invite-link-set: {e}"))?;
}
return Ok(());
}
let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
match existing_rowid {
Some(rowid) => {
conn.execute(
"UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
params![enc_locators, rowid],
)
.map_err(|e| format!("upsert invite-link-set: {e}"))?;
}
None => {
let enc_creator = enc_txt(creator_hex)?;
conn.execute(
"INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
params![community_id, enc_creator, enc_locators],
)
.map_err(|e| format!("upsert invite-link-set: {e}"))?;
}
}
Ok(())
}
pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
.map_err(|e| format!("prepare invite-link-sets: {e}"))?;
let rows = stmt
.query_map(params![community_id], |r| {
let creator_hex: String = r.get(0)?;
let json: String = r.get(1)?;
Ok((creator_hex, json))
})
.map_err(|e| format!("query invite-link-sets: {e}"))?;
let mut out = Vec::new();
for row in rows {
let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
}
Ok(out)
}
pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
params![pending as i64, community_id],
)
.map_err(|e| format!("set read_cut_pending: {e}"))?;
Ok(())
}
pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
let conn = super::get_write_connection_guard_static()?;
let changed = conn
.execute(
"UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
params![community_id],
)
.map_err(|e| format!("set dissolved: {e}"))?;
Ok(changed > 0)
}
pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
let conn = super::get_db_connection_guard_static()?;
let v: Option<i64> = conn
.query_row(
"SELECT dissolved FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get dissolved: {e}"))?;
Ok(v.unwrap_or(0) != 0)
}
pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
let conn = super::get_db_connection_guard_static()?;
let v: Option<i64> = conn
.query_row(
"SELECT read_cut_pending FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get read_cut_pending: {e}"))?;
Ok(v.unwrap_or(0) != 0)
}
pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
params![target as i64, community_id],
)
.map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
Ok(())
}
pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
let conn = super::get_db_connection_guard_static()?;
let v: Option<i64> = conn
.query_row(
"SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
params![community_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
Ok(v.unwrap_or(0) as u64)
}
pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
let conn = super::get_db_connection_guard_static()?;
let v: Option<i64> = conn
.query_row(
"SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
params![community_id, channel_id],
|r| r.get(0),
)
.optional()
.map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
Ok(v.unwrap_or(0) as u64)
}
pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
conn.execute(
"UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
params![server_epoch as i64, community_id, channel_id],
)
.map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
Ok(())
}
pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
let conn = super::get_db_connection_guard_static()?;
let mut stmt = conn
.prepare("SELECT community_id FROM communities ORDER BY created_at")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let mut ids = Vec::new();
for row in rows {
ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
}
Ok(ids)
}
pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
let conn = super::get_db_connection_guard_static()?;
let n: Option<i64> = conn
.query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())?;
Ok(n.map(crate::community::ConcordProtocol::from_i64))
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct CommunityMetaStash {
#[serde(default, skip_serializing_if = "Option::is_none")]
custom: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct ChannelMetaStash {
#[serde(default, skip_serializing_if = "Option::is_none")]
voice: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
extra: serde_json::Map<String, serde_json::Value>,
}
pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
let created = (c.created_at_ms / 1000) as i64;
let enc_root = enc_key(&c.community_root)?;
let enc_name = enc_txt(&c.name)?;
let enc_relays = enc_txt(&relays_json)?;
let enc_desc = enc_txt_opt(&c.description)?;
let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
let enc_icon = enc_txt_opt(&icon_json)?;
let enc_banner = enc_txt_opt(&banner_json)?;
let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
.then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
.transpose()?;
let enc_stash = enc_txt_opt(&stash_json)?;
let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
tx.execute(
"INSERT INTO communities
(community_id, server_root_key, name, relays, created_at, description,
server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(community_id) DO UPDATE SET
server_root_key=?2, name=?3, relays=?4, description=?6,
server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
icon=?11, banner=?12, meta_extra=?13",
params![
id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
enc_icon, enc_banner, enc_stash,
],
)
.map_err(|e| format!("save v2 community: {e}"))?;
for ch in &c.channels {
let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
let owner_of: Option<String> = tx
.query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
.optional()
.map_err(|e| format!("channel ownership check: {e}"))?;
if owner_of.is_some_and(|existing| existing != id_hex) {
continue;
}
let stored_key = ch.key.unwrap_or(c.community_root);
let enc_ch_key = enc_key(&stored_key)?;
let enc_ch_name = enc_txt(&ch.name)?;
let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
.then(|| {
serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
.map_err(|e| e.to_string())
})
.transpose()?;
let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
tx.execute(
"INSERT INTO community_channels
(channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(channel_id) DO UPDATE SET
channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
)
.map_err(|e| format!("save v2 channel: {e}"))?;
}
let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
if keep.is_empty() {
tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
.map_err(|e| format!("prune v2 channels: {e}"))?;
} else {
let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
binds.push(id_hex.clone());
binds.extend(keep);
tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
.map_err(|e| format!("prune v2 channels: {e}"))?;
}
tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
Ok(())
}
pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
use crate::community::v2::community::{ChannelV2, CommunityV2};
use crate::community::v2::control::CommunityIdentity;
let conn = super::get_db_connection_guard_static()?;
let id_hex = id.to_hex();
let row = conn
.query_row(
"SELECT server_root_key, name, relays, created_at, description,
server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
icon, banner, meta_extra
FROM communities WHERE community_id = ?1",
params![id_hex],
|r| {
Ok((
r.get::<_, Vec<u8>>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, i64>(5)?,
r.get::<_, i64>(6)?,
r.get::<_, i64>(7)?,
r.get::<_, Option<String>>(8)?,
r.get::<_, Option<String>>(9)?,
r.get::<_, Option<String>>(10)?,
r.get::<_, Option<String>>(11)?,
r.get::<_, Option<String>>(12)?,
))
},
)
.optional()
.map_err(|e| e.to_string())?;
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
else {
return Ok(None);
};
if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
return Ok(None);
}
let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
return Err("v2 community row is missing its owner commitment".to_string());
};
let community_root = dec_key(&root_blob)?;
let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
let mut channels = Vec::new();
{
let mut stmt = conn
.prepare(
"SELECT channel_id, channel_key, epoch, name, private, meta_extra
FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![id_hex], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Vec<u8>>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, Option<String>>(5)?,
))
})
.map_err(|e| e.to_string())?;
for row in rows {
let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
let private = private != 0;
let key = dec_key(&key_blob)?;
let ch_stash: ChannelMetaStash = ch_stash_e
.map(|s| dec_txt(&s))
.and_then(|j| serde_json::from_str(&j).ok())
.unwrap_or_default();
channels.push(ChannelV2 {
id: ChannelId(hex_id_to_32(&ch_hex)?),
name: dec_txt(&name_e),
private,
key: (private && key != community_root).then_some(key),
epoch: Epoch(epoch as u64),
voice: ch_stash.voice,
meta_custom: ch_stash.custom,
meta_extra: ch_stash.extra,
});
}
}
let icon = icon_e
.map(|s| dec_txt(&s))
.and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
let banner = banner_e
.map(|s| dec_txt(&s))
.and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
let stash: CommunityMetaStash = stash_e
.map(|s| dec_txt(&s))
.and_then(|j| serde_json::from_str(&j).ok())
.unwrap_or_default();
Ok(Some(CommunityV2 {
identity,
community_root,
root_epoch: Epoch(root_epoch as u64),
name: dec_txt(&name_e),
description: desc_e.map(|d| dec_txt(&d)),
icon,
banner,
meta_custom: stash.custom,
meta_extra: stash.extra,
relays,
channels,
dissolved: dissolved != 0,
created_at_ms: (created as u64).saturating_mul(1000),
}))
}
fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("stored value is not 32-byte hex".to_string());
}
Ok(crate::simd::hex::hex_to_bytes_32(hex))
}
pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
let conn = super::get_db_connection_guard_static()?;
let row = conn
.query_row(
"SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
params![community_id],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
)
.optional()
.map_err(|e| format!("load guestbook: {e}"))?;
let Some((events_e, cursor)) = row else {
return Ok((Vec::new(), 0));
};
let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
Ok((events, cursor.max(0) as u64))
}
pub fn set_guestbook(
community_id: &str,
events: &[crate::community::v2::guestbook::GuestbookEvent],
cursor_secs: u64,
) -> Result<(), String> {
let conn = super::get_write_connection_guard_static()?;
let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
let enc = enc_txt(&json)?;
conn.execute(
"INSERT INTO community_guestbook (community_id, events, cursor_secs)
VALUES (?1, ?2, ?3)
ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
params![community_id, enc, cursor_secs as i64],
)
.map_err(|e| format!("save guestbook: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
fn make_test_npub(n: u32) -> String {
const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut payload = vec![b'q'; 58];
let mut x = n as u64;
let mut i = 58;
while x > 0 && i > 0 {
i -= 1;
payload[i] = BECH32[(x as usize) % 32];
x /= 32;
}
format!("npub1{}", std::str::from_utf8(&payload).unwrap())
}
fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let account = make_test_npub(n);
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).unwrap();
(tmp, guard)
}
#[test]
fn edition_head_round_trips_and_upserts() {
let (_tmp, _guard) = init_test_db();
let cid = "f".repeat(64);
let entity = "a".repeat(64);
assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
let h1 = [0x11u8; 32];
set_edition_head(&cid, &entity, 1, &h1).unwrap();
assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
let h2 = [0x22u8; 32];
set_edition_head(&cid, &entity, 2, &h2).unwrap();
assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
let other = "b".repeat(64);
assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
}
#[test]
fn guestbook_round_trips_events_and_cursor() {
let (_tmp, _guard) = init_test_db();
let member = nostr_sdk::prelude::Keys::generate();
let ev = crate::community::v2::guestbook::GuestbookEvent {
rumor_id: [7u8; 32],
entry: crate::community::v2::guestbook::GuestbookEntry::Join {
member: member.public_key(),
invited_by: Some(("creator".into(), "label".into())),
at_ms: 1_000,
},
};
let cid = "d".repeat(64);
assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
let (events, cursor) = get_guestbook(&cid).unwrap();
assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
assert_eq!(cursor, 42);
}
#[test]
fn v2_images_round_trip_and_read_as_v1_community_images() {
let (_tmp, _guard) = init_test_db();
let owner = nostr_sdk::prelude::Keys::generate();
let g = crate::community::v2::control::genesis(
&owner,
crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
1_000,
)
.unwrap();
let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
let mut extra = serde_json::Map::new();
extra.insert("ext".into(), serde_json::Value::String("webp".into()));
c.icon = Some(crate::community::v2::control::ImageRef {
url: "https://blossom.example/abc".into(),
key: "0".repeat(64),
nonce: "1".repeat(32),
hash: "a".repeat(64),
extra,
});
c.meta_custom = Some({
let mut m = serde_json::Map::new();
m.insert("k".into(), serde_json::Value::from("v"));
m
});
c.channels[0].voice = Some(true);
c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
save_community_v2(&c).unwrap();
let re = load_community_v2(c.id()).unwrap().unwrap();
assert_eq!(re.icon, c.icon);
assert_eq!(re.banner, None);
assert_eq!(re.meta_custom, c.meta_custom);
assert_eq!(re.channels[0].voice, Some(true));
assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
let v1 = load_community(c.id()).unwrap().unwrap();
let img = v1.icon.expect("v1 reader sees the v2 icon");
assert_eq!(img.url, "https://blossom.example/abc");
assert_eq!(img.ext, "webp");
assert_eq!(img.hash, "a".repeat(64));
}
#[test]
fn server_root_epoch_round_trips() {
let (_tmp, _guard) = init_test_db();
let mut c = Community::create("HQ", "general", vec![]);
save_community(&c).unwrap();
assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
c.server_root_epoch = Epoch(5);
c.server_root_key = ServerRootKey([0x42u8; 32]);
save_community(&c).unwrap();
let loaded = load_community(&c.id).unwrap().unwrap();
assert_eq!(loaded.server_root_epoch, Epoch(5));
assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
}
#[test]
fn epoch_key_archive_retains_every_epoch() {
let (_tmp, _guard) = init_test_db();
let cid = "f".repeat(64);
let scope = "a".repeat(64);
store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
let held = held_epoch_keys(&cid, &scope).unwrap();
assert_eq!(held.len(), 3, "all three epoch keys retained");
assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
assert_eq!(
held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
None,
"epoch 1 under a different scope is not the channel's key"
);
}
#[test]
fn save_community_populates_the_epoch_archive() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("HQ", "general", vec![]);
save_community(&c).unwrap();
let cid = c.id.to_hex();
assert_eq!(
held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
Some(c.server_root_key.as_bytes())
);
let chan = &c.channels[0];
assert_eq!(
held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
Some(chan.key.as_bytes())
);
}
#[test]
fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
let (_tmp, _guard) = init_test_db();
crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
crate::state::set_encryption_enabled(true);
let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
c.server_root_key = ServerRootKey([0x42u8; 32]);
c.description = Some("top secret".into());
save_community(&c).unwrap();
let cid = c.id.to_hex();
set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
{
let conn = crate::db::get_db_connection_guard_static().unwrap();
let (root_len, name, banlist): (i64, String, String) = conn
.query_row(
"SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
params![cid],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
let key_len: i64 = conn
.query_row(
"SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
params![cid],
|r| r.get(0),
)
.unwrap();
assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
}
let loaded = load_community(&c.id).unwrap().unwrap();
assert_eq!(loaded.name, "Secret HQ");
assert_eq!(loaded.description.as_deref(), Some("top secret"));
assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
crate::state::set_encryption_enabled(false);
crate::state::ENCRYPTION_KEY.clear(&[]);
}
#[test]
fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
let (_tmp, _guard) = init_test_db();
crate::state::set_encryption_enabled(false);
let mut c = Community::create("Legacy HQ", "general", vec![]);
c.server_root_key = ServerRootKey([0x42u8; 32]);
save_community(&c).unwrap();
crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
crate::state::set_encryption_enabled(true);
let loaded = load_community(&c.id).unwrap().unwrap();
assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
crate::state::set_encryption_enabled(false);
crate::state::ENCRYPTION_KEY.clear(&[]);
}
#[test]
fn save_and_load_round_trip() {
let (_tmp, _guard) = init_test_db();
let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
save_community(&original).unwrap();
let loaded = load_community(&original.id).unwrap().expect("present");
assert_eq!(loaded.id, original.id);
assert_eq!(loaded.name, "Vector HQ");
assert_eq!(loaded.relays, original.relays);
assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.channels[0].id, original.channels[0].id);
assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
assert_eq!(loaded.channels[0].epoch, Epoch(0));
assert_eq!(loaded.channels[0].name, "general");
}
#[test]
fn owner_is_protected_from_the_banlist_a_member_is_not() {
use nostr_sdk::JsonUtil;
let (_tmp, _guard) = init_test_db();
let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
community.owner_attestation = Some(
crate::community::owner::build_owner_attestation_unsigned(
owner_id.public_key(),
&community.id.to_hex(),
)
.sign_with_keys(&owner_id)
.unwrap()
.as_json(),
);
save_community(&community).unwrap();
let member = Keys::generate();
set_community_banlist(
&community.id.to_hex(),
&[owner_id.public_key().to_hex(), member.public_key().to_hex()],
1,
)
.unwrap();
let loaded = load_community(&community.id).unwrap().unwrap();
let ch = &loaded.channels[0];
assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
}
#[test]
fn loaded_keys_actually_decrypt() {
let (_tmp, _guard) = init_test_db();
let original = Community::create("HQ", "general", vec![]);
save_community(&original).unwrap();
let loaded = load_community(&original.id).unwrap().unwrap();
let author = nostr_sdk::prelude::Keys::generate();
let chan = &original.channels[0];
let sealed = crate::community::envelope::seal_message(
&author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
)
.unwrap();
let opened = crate::community::envelope::open_message(
&sealed,
&loaded.channels[0].key,
&loaded.channels[0].id,
loaded.channels[0].epoch,
)
.unwrap();
assert_eq!(opened.content, "persisted!");
}
#[test]
fn member_view_round_trips() {
let (_tmp, _guard) = init_test_db();
let member = Community {
id: CommunityId([7u8; 32]),
server_root_key: ServerRootKey([8u8; 32]),
server_root_epoch: Epoch(0),
name: "Joined".into(),
description: None,
icon: None,
banner: None,
relays: vec!["wss://r".into()],
channels: vec![Channel {
id: ChannelId([9u8; 32]),
key: ChannelKey([10u8; 32]),
epoch: Epoch(0),
name: "general".into(),
banned: Vec::new(),
protected: Vec::new(), roster: Default::default(),
epoch_keys: Vec::new(),
dissolved: false,
}],
owner_attestation: None,
dissolved: false,
};
save_community(&member).unwrap();
let loaded = load_community(&member.id).unwrap().expect("present");
assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
}
#[test]
fn large_epoch_round_trips_losslessly() {
let (_tmp, _guard) = init_test_db();
let mut c = Community::create("HQ", "g", vec![]);
c.channels[0].epoch = Epoch(u64::MAX - 7);
save_community(&c).unwrap();
let loaded = load_community(&c.id).unwrap().unwrap();
assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
}
#[test]
fn malformed_channel_id_row_errors_not_corrupts() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("HQ", "g", vec![]);
save_community(&c).unwrap();
{
let conn = crate::db::get_write_connection_guard_static().unwrap();
conn.execute(
"INSERT OR REPLACE INTO community_channels
(channel_id, community_id, channel_key, epoch, name, created_at)
VALUES (?1, ?2, ?3, 0, 'bad', 0)",
rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
)
.unwrap();
}
assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
}
#[test]
fn message_key_store_take_round_trip() {
let (_tmp, _guard) = init_test_db();
let eph = Keys::generate();
let relays = vec!["wss://r.one".to_string()];
store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
assert_eq!(
loaded.secret_key().as_secret_bytes(),
eph.secret_key().as_secret_bytes()
);
assert_eq!(outer, "outer_evid");
assert_eq!(r, relays);
assert!(take_message_key("inner_msg_id").unwrap().is_none());
}
#[test]
fn missing_community_is_none() {
let (_tmp, _guard) = init_test_db();
let absent = CommunityId([0x33u8; 32]);
assert!(load_community(&absent).unwrap().is_none());
}
#[test]
fn list_ids_reflects_saved() {
let (_tmp, _guard) = init_test_db();
let a = Community::create("A", "g", vec![]);
let b = Community::create("B", "g", vec![]);
save_community(&a).unwrap();
save_community(&b).unwrap();
let ids = list_community_ids().unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&a.id) && ids.contains(&b.id));
}
#[test]
fn delete_community_clears_all_local_state() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("HQ", "general", vec!["r1".into()]);
save_community(&c).unwrap();
let cid = c.id.to_hex();
save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
delete_community(&cid).unwrap();
assert!(!community_exists(&c.id).unwrap());
assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
assert!(list_public_invites(&cid).unwrap().is_empty());
assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
}
#[test]
fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("HQ", "general", vec!["r1".into()]);
save_community(&c).unwrap();
let cid = c.id.to_hex();
save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
delete_community_retain_keys(&cid).unwrap();
assert!(!community_exists(&c.id).unwrap());
assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
assert!(list_public_invites(&cid).unwrap().is_empty());
assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
"base epoch keys retained for self-scrub");
assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
"channel epoch keys retained for self-scrub");
}
#[test]
fn channel_resolves_to_owning_community() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("HQ", "general", vec![]);
save_community(&c).unwrap();
let chan = c.channels[0].id.to_hex();
assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
}
#[test]
fn community_exists_reflects_saved() {
let (_tmp, _guard) = init_test_db();
let c = Community::create("A", "g", vec![]);
assert!(!community_exists(&c.id).unwrap());
save_community(&c).unwrap();
assert!(community_exists(&c.id).unwrap());
}
#[test]
fn pending_invite_first_wins_and_round_trips() {
let (_tmp, _guard) = init_test_db();
let cid = "ab".repeat(32);
assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
assert!(pending_invite_exists(&cid).unwrap());
let listed = list_pending_invites().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].community_id, cid);
assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
assert_eq!(listed[0].inviter_npub, "npub1inviter");
assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
delete_pending_invite(&cid).unwrap();
assert!(!pending_invite_exists(&cid).unwrap());
assert!(get_pending_invite(&cid).unwrap().is_none());
}
#[test]
fn purge_drops_invites_for_held_communities_only() {
let (_tmp, _guard) = init_test_db();
let held = Community::create("Held", "general", vec![]);
save_community(&held).unwrap();
let held_hex = held.id.to_hex();
save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
let stranger = "ab".repeat(32);
save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
let n = purge_pending_invites_for_held_communities().unwrap();
assert_eq!(n, 1, "only the held community's invite is purged");
assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
}
#[test]
fn decline_drops_pending_invite() {
let (_tmp, _guard) = init_test_db();
let cid = "cd".repeat(32);
save_pending_invite(&cid, "{}", "npub1x").unwrap();
delete_pending_invite(&cid).unwrap();
assert!(!pending_invite_exists(&cid).unwrap());
}
#[test]
fn pending_invites_are_capped_keeping_the_newest() {
let (_tmp, _guard) = init_test_db();
for i in 0..150u32 {
let cid = format!("{:064x}", i);
save_pending_invite(&cid, "{}", "npub1x").unwrap();
}
let all = list_pending_invites().unwrap();
assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
for i in 150..400u32 {
let cid = format!("{:064x}", i);
save_pending_invite(&cid, "{}", "npub1x").unwrap();
}
assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
}
}