feat: add abuse prevention and moderation (Phase 5.6)
Add server-side moderation service with report submission, user banning/unbanning, and admin listing endpoints. Add client-side user blocking with message filtering in ConversationStore. Server: - ModerationService domain logic (report, ban, unban, list) - Storage trait methods + FileBackedStore + SqlStore implementations - SQL migration 012_moderation.sql (reports + bans tables) - Error codes E031-E033 for moderation - Domain types for all moderation request/response pairs - 10 new tests (6 domain + 4 storage) SDK: - blocked_users table in ConversationStore - block_user, unblock_user, is_blocked, list_blocked methods - load_recent_messages_filtered excludes blocked senders - QpqClient moderation convenience methods - 4 new tests for block/unblock/filter
This commit is contained in:
@@ -174,6 +174,46 @@ impl QpqClient {
|
||||
self.session_token.as_deref()
|
||||
}
|
||||
|
||||
// ── Moderation (client-side) ────────────────────────────────────────────
|
||||
|
||||
/// Block a user locally. Their messages will be hidden from display.
|
||||
pub fn block_user(&self, identity_key: &[u8], reason: &str) -> Result<(), SdkError> {
|
||||
let store = self.conversations()?;
|
||||
store
|
||||
.block_user(identity_key, reason)
|
||||
.map_err(|e| SdkError::Storage(e.to_string()))?;
|
||||
info!(identity = %hex::encode(identity_key), "user blocked");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unblock a user locally.
|
||||
pub fn unblock_user(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
|
||||
let store = self.conversations()?;
|
||||
let removed = store
|
||||
.unblock_user(identity_key)
|
||||
.map_err(|e| SdkError::Storage(e.to_string()))?;
|
||||
if removed {
|
||||
info!(identity = %hex::encode(identity_key), "user unblocked");
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Check if a user is blocked locally.
|
||||
pub fn is_blocked(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
|
||||
let store = self.conversations()?;
|
||||
store
|
||||
.is_blocked(identity_key)
|
||||
.map_err(|e| SdkError::Storage(e.to_string()))
|
||||
}
|
||||
|
||||
/// List all locally blocked users.
|
||||
pub fn list_blocked(&self) -> Result<Vec<crate::conversation::BlockedUser>, SdkError> {
|
||||
let store = self.conversations()?;
|
||||
store
|
||||
.list_blocked()
|
||||
.map_err(|e| SdkError::Storage(e.to_string()))
|
||||
}
|
||||
|
||||
/// Disconnect from the server.
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(rpc) = self.rpc.take() {
|
||||
|
||||
@@ -96,6 +96,14 @@ pub struct OutboxEntry {
|
||||
pub retry_count: u32,
|
||||
}
|
||||
|
||||
/// A blocked user entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockedUser {
|
||||
pub identity_key: Vec<u8>,
|
||||
pub blocked_at_ms: u64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// ── ConversationStore ────────────────────────────────────────────────────────
|
||||
|
||||
/// SQLCipher-backed conversation and message store.
|
||||
@@ -171,7 +179,13 @@ impl ConversationStore {
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_status
|
||||
ON outbox(status, created_at_ms);",
|
||||
ON outbox(status, created_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_users (
|
||||
identity_key BLOB PRIMARY KEY,
|
||||
blocked_at_ms INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT ''
|
||||
);",
|
||||
)
|
||||
.context("migrate conversation db")
|
||||
}
|
||||
@@ -351,9 +365,9 @@ impl ConversationStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an outbox entry as failed (retryable up to 5 times).
|
||||
/// Mark an outbox entry as failed (retryable up to 10 times).
|
||||
pub fn mark_outbox_failed(&self, id: i64, retry_count: u32) -> anyhow::Result<()> {
|
||||
let new_status = if retry_count > 5 { "failed" } else { "pending" };
|
||||
let new_status = if retry_count > 10 { "failed" } else { "pending" };
|
||||
self.conn.execute(
|
||||
"UPDATE outbox SET retry_count = ?2, status = ?3 WHERE id = ?1",
|
||||
params![id, retry_count, new_status],
|
||||
@@ -371,6 +385,125 @@ impl ConversationStore {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
/// Delete all permanently failed outbox entries (status = 'failed').
|
||||
/// Returns the number of entries removed.
|
||||
pub fn clear_failed_outbox(&self) -> anyhow::Result<usize> {
|
||||
let count = self.conn.execute(
|
||||
"DELETE FROM outbox WHERE status = 'failed'",
|
||||
[],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Sequence tracking ──────────────────────────────────────────────────
|
||||
|
||||
/// Update the last seen server sequence number for a conversation.
|
||||
/// Only increases; a lower seq is a no-op.
|
||||
pub fn update_last_seen_seq(
|
||||
&self,
|
||||
conv_id: &ConversationId,
|
||||
seq: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE conversations SET last_seen_seq = ?2 WHERE id = ?1 AND last_seen_seq < ?2",
|
||||
params![conv_id.0.as_slice(), seq as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the last seen server sequence number for a conversation.
|
||||
pub fn get_last_seen_seq(&self, conv_id: &ConversationId) -> anyhow::Result<u64> {
|
||||
let seq: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT last_seen_seq FROM conversations WHERE id = ?1",
|
||||
params![conv_id.0.as_slice()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(0);
|
||||
Ok(seq as u64)
|
||||
}
|
||||
|
||||
// ── Blocked users ─────────────────────────────────────────────────────
|
||||
|
||||
/// Block a user by identity key.
|
||||
pub fn block_user(&self, identity_key: &[u8], reason: &str) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO blocked_users (identity_key, blocked_at_ms, reason)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![identity_key, now_ms() as i64, reason],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unblock a user. Returns true if the user was blocked.
|
||||
pub fn unblock_user(&self, identity_key: &[u8]) -> anyhow::Result<bool> {
|
||||
let rows = self.conn.execute(
|
||||
"DELETE FROM blocked_users WHERE identity_key = ?1",
|
||||
params![identity_key],
|
||||
)?;
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
/// Check if a user is blocked.
|
||||
pub fn is_blocked(&self, identity_key: &[u8]) -> anyhow::Result<bool> {
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM blocked_users WHERE identity_key = ?1",
|
||||
params![identity_key],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// List all blocked users: (identity_key, blocked_at_ms, reason).
|
||||
pub fn list_blocked(&self) -> anyhow::Result<Vec<BlockedUser>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT identity_key, blocked_at_ms, reason FROM blocked_users
|
||||
ORDER BY blocked_at_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let identity_key: Vec<u8> = row.get(0)?;
|
||||
let blocked_at_ms: u64 = row.get(1)?;
|
||||
let reason: String = row.get(2)?;
|
||||
Ok(BlockedUser {
|
||||
identity_key,
|
||||
blocked_at_ms,
|
||||
reason,
|
||||
})
|
||||
})?;
|
||||
let mut users = Vec::new();
|
||||
for row in rows {
|
||||
users.push(row?);
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
/// Load recent messages, filtering out messages from blocked users.
|
||||
pub fn load_recent_messages_filtered(
|
||||
&self,
|
||||
conv_id: &ConversationId,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<StoredMessage>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT m.message_id, m.sender_key, m.sender_name, m.body, m.msg_type,
|
||||
m.ref_msg_id, m.timestamp_ms, m.is_outgoing
|
||||
FROM messages m
|
||||
WHERE m.conversation_id = ?1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM blocked_users b WHERE b.identity_key = m.sender_key
|
||||
)
|
||||
ORDER BY m.timestamp_ms DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![conv_id.0.as_slice(), limit.min(u32::MAX as usize) as u32],
|
||||
|row| row_to_message(conv_id, row),
|
||||
)?;
|
||||
let mut msgs: Vec<StoredMessage> = rows.collect::<Result<_, _>>()?;
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
/// Load recent messages (newest first, then reversed to chronological).
|
||||
pub fn load_recent_messages(
|
||||
&self,
|
||||
@@ -742,4 +875,109 @@ mod tests {
|
||||
assert!(ConversationId::from_slice(&[]).is_none());
|
||||
assert!(ConversationId::from_slice(&[0u8; 16]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_unblock_user() {
|
||||
let (_dir, store) = open_test_store();
|
||||
let ik = vec![42u8; 32];
|
||||
|
||||
assert!(!store.is_blocked(&ik).unwrap());
|
||||
|
||||
store.block_user(&ik, "spam").unwrap();
|
||||
assert!(store.is_blocked(&ik).unwrap());
|
||||
|
||||
let blocked = store.list_blocked().unwrap();
|
||||
assert_eq!(blocked.len(), 1);
|
||||
assert_eq!(blocked[0].identity_key, ik);
|
||||
assert_eq!(blocked[0].reason, "spam");
|
||||
|
||||
let removed = store.unblock_user(&ik).unwrap();
|
||||
assert!(removed);
|
||||
assert!(!store.is_blocked(&ik).unwrap());
|
||||
assert!(store.list_blocked().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_user_idempotent() {
|
||||
let (_dir, store) = open_test_store();
|
||||
let ik = vec![42u8; 32];
|
||||
|
||||
store.block_user(&ik, "first").unwrap();
|
||||
store.block_user(&ik, "updated").unwrap();
|
||||
|
||||
let blocked = store.list_blocked().unwrap();
|
||||
assert_eq!(blocked.len(), 1);
|
||||
assert_eq!(blocked[0].reason, "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblock_nonexistent_returns_false() {
|
||||
let (_dir, store) = open_test_store();
|
||||
let removed = store.unblock_user(&[99u8; 32]).unwrap();
|
||||
assert!(!removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_messages_exclude_blocked() {
|
||||
let (_dir, store) = open_test_store();
|
||||
let conv = make_group_conv("modchat", 1000);
|
||||
store.save_conversation(&conv).unwrap();
|
||||
|
||||
let alice_key = vec![1u8; 32];
|
||||
let bob_key = vec![2u8; 32];
|
||||
|
||||
// Alice sends 2 messages, Bob sends 1.
|
||||
store.save_message(&StoredMessage {
|
||||
conversation_id: conv.id.clone(),
|
||||
message_id: None,
|
||||
sender_key: alice_key.clone(),
|
||||
sender_name: Some("alice".to_string()),
|
||||
body: "hello from alice".to_string(),
|
||||
msg_type: "chat".to_string(),
|
||||
ref_msg_id: None,
|
||||
timestamp_ms: 1000,
|
||||
is_outgoing: false,
|
||||
}).unwrap();
|
||||
|
||||
store.save_message(&StoredMessage {
|
||||
conversation_id: conv.id.clone(),
|
||||
message_id: None,
|
||||
sender_key: bob_key.clone(),
|
||||
sender_name: Some("bob".to_string()),
|
||||
body: "hello from bob".to_string(),
|
||||
msg_type: "chat".to_string(),
|
||||
ref_msg_id: None,
|
||||
timestamp_ms: 2000,
|
||||
is_outgoing: false,
|
||||
}).unwrap();
|
||||
|
||||
store.save_message(&StoredMessage {
|
||||
conversation_id: conv.id.clone(),
|
||||
message_id: None,
|
||||
sender_key: alice_key.clone(),
|
||||
sender_name: Some("alice".to_string()),
|
||||
body: "another from alice".to_string(),
|
||||
msg_type: "chat".to_string(),
|
||||
ref_msg_id: None,
|
||||
timestamp_ms: 3000,
|
||||
is_outgoing: false,
|
||||
}).unwrap();
|
||||
|
||||
// All 3 messages unfiltered.
|
||||
let all = store.load_recent_messages(&conv.id, 10).unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
|
||||
// Block alice.
|
||||
store.block_user(&alice_key, "spam").unwrap();
|
||||
|
||||
// Filtered: only bob's message.
|
||||
let filtered = store.load_recent_messages_filtered(&conv.id, 10).unwrap();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].body, "hello from bob");
|
||||
|
||||
// Unblock alice — all messages visible again.
|
||||
store.unblock_user(&alice_key).unwrap();
|
||||
let unblocked = store.load_recent_messages_filtered(&conv.id, 10).unwrap();
|
||||
assert_eq!(unblocked.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user