feat: add 11 features and bug fixes across server, SDK, and client
Server fixes: - Wire v2 moderation handlers to ModerationService (SQL persistence) — bans now survive restarts instead of living in-memory DashMap - Add admin role enforcement via QPC_ADMIN_KEYS env var for ban/unban - Fix audit.rs now_iso8601() to emit actual ISO-8601 timestamps - Add group admin authorization — only creator can remove members or update metadata Server features: - Add DeleteBlob RPC (method 602) with filesystem cleanup - Register delete_blob in v2 handler method registry SDK features: - Add ClientEvent::IdentityKeyChanged for safety number change alerts - Add ClientEvent::ReadReceipt and DeliveryConfirmation variants - Add peer_identity_keys table with store/get methods for key tracking - Add search_messages() full-text search across all conversations - Add delete_conversation() with cascading message/outbox cleanup Client features: - Wire v2 TUI message sending to SDK MLS encryption pipeline - Add /search command to v2 REPL with cross-conversation results - Add /delete-conversation command to v2 REPL - Add unread count badges in v1 TUI sidebar (yellow+bold styling)
This commit is contained in:
@@ -185,6 +185,13 @@ impl ConversationStore {
|
||||
identity_key BLOB PRIMARY KEY,
|
||||
blocked_at_ms INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_identity_keys (
|
||||
username TEXT PRIMARY KEY,
|
||||
identity_key BLOB NOT NULL,
|
||||
first_seen_ms INTEGER NOT NULL,
|
||||
last_seen_ms INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.context("migrate conversation db")
|
||||
@@ -524,6 +531,112 @@ impl ConversationStore {
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
// ── Peer identity key tracking ──────────────────────────────────────────
|
||||
|
||||
/// Look up the stored identity key for a peer by username.
|
||||
pub fn get_peer_identity_key(&self, username: &str) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let key: Option<Vec<u8>> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT identity_key FROM peer_identity_keys WHERE username = ?1",
|
||||
params![username],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store (or update) a peer's identity key. Returns the previous key if it changed.
|
||||
pub fn store_peer_identity_key(
|
||||
&self,
|
||||
username: &str,
|
||||
identity_key: &[u8],
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
|
||||
let old = self.get_peer_identity_key(username)?;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO peer_identity_keys (username, identity_key, first_seen_ms, last_seen_ms)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(username) DO UPDATE SET identity_key = ?2, last_seen_ms = ?3",
|
||||
params![username, identity_key, now_ms],
|
||||
)?;
|
||||
|
||||
// Return the old key only if it's different from the new one.
|
||||
match old {
|
||||
Some(ref prev) if prev != identity_key => Ok(old),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full-text search ────────────────────────────────────────────────────
|
||||
|
||||
/// Search messages across all conversations by body text.
|
||||
pub fn search_messages(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<SearchResult>> {
|
||||
let pattern = format!("%{query}%");
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT m.conversation_id, c.display_name, m.sender_name, m.body,
|
||||
m.timestamp_ms, m.message_id
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.body LIKE ?1
|
||||
ORDER BY m.timestamp_ms DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![pattern, limit.min(u32::MAX as usize) as u32],
|
||||
|row| {
|
||||
let conv_id_raw: Vec<u8> = row.get(0)?;
|
||||
let mut conv_id = [0u8; 16];
|
||||
if conv_id_raw.len() == 16 {
|
||||
conv_id.copy_from_slice(&conv_id_raw);
|
||||
}
|
||||
Ok(SearchResult {
|
||||
conversation_id: ConversationId(conv_id),
|
||||
conversation_name: row.get(1)?,
|
||||
sender_name: row.get(2)?,
|
||||
body: row.get(3)?,
|
||||
timestamp_ms: row.get::<_, i64>(4)? as u64,
|
||||
message_id: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
// ── Conversation deletion ───────────────────────────────────────────────
|
||||
|
||||
/// Delete a conversation and all its messages.
|
||||
pub fn delete_conversation(&self, id: &ConversationId) -> anyhow::Result<bool> {
|
||||
self.conn
|
||||
.execute("DELETE FROM messages WHERE conversation_id = ?1", params![id.0.as_slice()])?;
|
||||
self.conn
|
||||
.execute("DELETE FROM outbox WHERE conversation_id = ?1", params![id.0.as_slice()])?;
|
||||
let rows = self
|
||||
.conn
|
||||
.execute("DELETE FROM conversations WHERE id = ?1", params![id.0.as_slice()])?;
|
||||
Ok(rows > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A search result across conversations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SearchResult {
|
||||
pub conversation_id: ConversationId,
|
||||
pub conversation_name: String,
|
||||
pub sender_name: Option<String>,
|
||||
pub body: String,
|
||||
pub timestamp_ms: u64,
|
||||
pub message_id: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user