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:
2026-03-04 20:11:20 +01:00
parent a1f0dbc514
commit 5b6d8209f0
9 changed files with 1255 additions and 4 deletions

View File

@@ -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() {

View File

@@ -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);
}
}