chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
304
crates/quicprochat-server/src/domain/moderation.rs
Normal file
304
crates/quicprochat-server/src/domain/moderation.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
//! Moderation domain logic — report, ban, unban, list.
|
||||
//!
|
||||
//! Pure business logic operating on `Store` trait and domain types.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::storage::Store;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
/// Shared state needed by moderation operations.
|
||||
pub struct ModerationService {
|
||||
pub store: Arc<dyn Store>,
|
||||
}
|
||||
|
||||
impl ModerationService {
|
||||
/// Submit an encrypted report for a message.
|
||||
pub fn report_message(
|
||||
&self,
|
||||
req: ReportMessageReq,
|
||||
) -> Result<ReportMessageResp, DomainError> {
|
||||
if req.encrypted_report.is_empty() {
|
||||
return Err(DomainError::BadParams(
|
||||
"encrypted report must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.store
|
||||
.store_report(
|
||||
&req.encrypted_report,
|
||||
&req.conversation_id,
|
||||
&req.reporter_identity,
|
||||
)
|
||||
.map_err(DomainError::Storage)?;
|
||||
|
||||
tracing::info!(
|
||||
reporter_prefix = %hex_prefix(&req.reporter_identity),
|
||||
"audit: message reported"
|
||||
);
|
||||
|
||||
Ok(ReportMessageResp { accepted: true })
|
||||
}
|
||||
|
||||
/// Ban a user by identity key.
|
||||
pub fn ban_user(&self, req: BanUserReq) -> Result<BanUserResp, DomainError> {
|
||||
if req.identity_key.len() != 32 {
|
||||
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||
}
|
||||
|
||||
let expires_at = if req.duration_secs == 0 {
|
||||
0 // permanent
|
||||
} else {
|
||||
now_secs() + req.duration_secs
|
||||
};
|
||||
|
||||
self.store
|
||||
.ban_user(&req.identity_key, &req.reason, expires_at)
|
||||
.map_err(DomainError::Storage)?;
|
||||
|
||||
tracing::info!(
|
||||
identity_prefix = %hex_prefix(&req.identity_key),
|
||||
reason = %req.reason,
|
||||
expires_at,
|
||||
"audit: user banned"
|
||||
);
|
||||
|
||||
Ok(BanUserResp { success: true })
|
||||
}
|
||||
|
||||
/// Unban a user by identity key.
|
||||
pub fn unban_user(&self, req: UnbanUserReq) -> Result<UnbanUserResp, DomainError> {
|
||||
if req.identity_key.len() != 32 {
|
||||
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||
}
|
||||
|
||||
let removed = self
|
||||
.store
|
||||
.unban_user(&req.identity_key)
|
||||
.map_err(DomainError::Storage)?;
|
||||
|
||||
if removed {
|
||||
tracing::info!(
|
||||
identity_prefix = %hex_prefix(&req.identity_key),
|
||||
"audit: user unbanned"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(UnbanUserResp { success: removed })
|
||||
}
|
||||
|
||||
/// Check if a user is currently banned.
|
||||
pub fn check_ban(&self, identity_key: &[u8]) -> Result<Option<String>, DomainError> {
|
||||
self.store
|
||||
.is_banned(identity_key)
|
||||
.map_err(DomainError::Storage)
|
||||
}
|
||||
|
||||
/// List reports with pagination.
|
||||
pub fn list_reports(&self, req: ListReportsReq) -> Result<ListReportsResp, DomainError> {
|
||||
let raw = self
|
||||
.store
|
||||
.list_reports(req.limit, req.offset)
|
||||
.map_err(DomainError::Storage)?;
|
||||
|
||||
let reports = raw
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, encrypted_report, conversation_id, reporter_identity, timestamp)| {
|
||||
ReportEntry {
|
||||
id,
|
||||
encrypted_report,
|
||||
conversation_id,
|
||||
reporter_identity,
|
||||
timestamp,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(ListReportsResp { reports })
|
||||
}
|
||||
|
||||
/// List all currently banned users.
|
||||
pub fn list_banned(&self) -> Result<ListBannedResp, DomainError> {
|
||||
let raw = self.store.list_banned().map_err(DomainError::Storage)?;
|
||||
|
||||
let users = raw
|
||||
.into_iter()
|
||||
.map(
|
||||
|(identity_key, reason, banned_at, expires_at)| BannedUserEntry {
|
||||
identity_key,
|
||||
reason,
|
||||
banned_at,
|
||||
expires_at,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(ListBannedResp { users })
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn hex_prefix(bytes: &[u8]) -> String {
|
||||
let len = bytes.len().min(4);
|
||||
let hex: String = bytes[..len].iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("{hex}...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::FileBackedStore;
|
||||
|
||||
fn test_service() -> (tempfile::TempDir, ModerationService) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(FileBackedStore::open(dir.path()).unwrap());
|
||||
let svc = ModerationService { store };
|
||||
(dir, svc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_store_and_list() {
|
||||
let (_dir, svc) = test_service();
|
||||
|
||||
let resp = svc
|
||||
.report_message(ReportMessageReq {
|
||||
encrypted_report: vec![1, 2, 3],
|
||||
conversation_id: vec![10; 16],
|
||||
reporter_identity: vec![20; 32],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(resp.accepted);
|
||||
|
||||
let reports = svc
|
||||
.list_reports(ListReportsReq {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(reports.reports.len(), 1);
|
||||
assert_eq!(reports.reports[0].encrypted_report, vec![1, 2, 3]);
|
||||
assert_eq!(reports.reports[0].conversation_id, vec![10; 16]);
|
||||
assert_eq!(reports.reports[0].reporter_identity, vec![20; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_empty_rejected() {
|
||||
let (_dir, svc) = test_service();
|
||||
let result = svc.report_message(ReportMessageReq {
|
||||
encrypted_report: vec![],
|
||||
conversation_id: vec![10; 16],
|
||||
reporter_identity: vec![20; 32],
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ban_unban_lifecycle() {
|
||||
let (_dir, svc) = test_service();
|
||||
let ik = vec![1u8; 32];
|
||||
|
||||
// Not banned initially.
|
||||
assert!(svc.check_ban(&ik).unwrap().is_none());
|
||||
|
||||
// Ban permanently.
|
||||
let resp = svc
|
||||
.ban_user(BanUserReq {
|
||||
identity_key: ik.clone(),
|
||||
reason: "spam".into(),
|
||||
duration_secs: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(resp.success);
|
||||
|
||||
// Now banned.
|
||||
let reason = svc.check_ban(&ik).unwrap();
|
||||
assert_eq!(reason, Some("spam".to_string()));
|
||||
|
||||
// Listed in banned users.
|
||||
let banned = svc.list_banned().unwrap();
|
||||
assert_eq!(banned.users.len(), 1);
|
||||
assert_eq!(banned.users[0].identity_key, ik);
|
||||
assert_eq!(banned.users[0].reason, "spam");
|
||||
assert_eq!(banned.users[0].expires_at, 0); // permanent
|
||||
|
||||
// Unban.
|
||||
let resp = svc.unban_user(UnbanUserReq { identity_key: ik.clone() }).unwrap();
|
||||
assert!(resp.success);
|
||||
|
||||
// No longer banned.
|
||||
assert!(svc.check_ban(&ik).unwrap().is_none());
|
||||
assert!(svc.list_banned().unwrap().users.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ban_invalid_identity_key() {
|
||||
let (_dir, svc) = test_service();
|
||||
let result = svc.ban_user(BanUserReq {
|
||||
identity_key: vec![1u8; 16], // wrong length
|
||||
reason: "test".into(),
|
||||
duration_secs: 0,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_pagination() {
|
||||
let (_dir, svc) = test_service();
|
||||
|
||||
for i in 0..5u8 {
|
||||
svc.report_message(ReportMessageReq {
|
||||
encrypted_report: vec![i],
|
||||
conversation_id: vec![10; 16],
|
||||
reporter_identity: vec![20; 32],
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let page1 = svc
|
||||
.list_reports(ListReportsReq {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page1.reports.len(), 2);
|
||||
assert_eq!(page1.reports[0].encrypted_report, vec![0]);
|
||||
|
||||
let page2 = svc
|
||||
.list_reports(ListReportsReq {
|
||||
limit: 2,
|
||||
offset: 2,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page2.reports.len(), 2);
|
||||
assert_eq!(page2.reports[0].encrypted_report, vec![2]);
|
||||
|
||||
let page3 = svc
|
||||
.list_reports(ListReportsReq {
|
||||
limit: 2,
|
||||
offset: 4,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page3.reports.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unban_nonexistent_returns_false() {
|
||||
let (_dir, svc) = test_service();
|
||||
let resp = svc
|
||||
.unban_user(UnbanUserReq {
|
||||
identity_key: vec![99u8; 32],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!resp.success);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user