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:
2026-04-04 23:31:37 +02:00
parent 4dadd01c6b
commit f58ce2529d
14 changed files with 662 additions and 127 deletions

View File

@@ -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 ──────────────────────────────────────────────────────────────────