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.
200 lines
6.5 KiB
Rust
200 lines
6.5 KiB
Rust
//! Moderation handlers — report, ban, unban, list reports, list banned.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use bytes::Bytes;
|
|
use prost::Message;
|
|
use quicprochat_proto::qpc::v1;
|
|
use quicprochat_rpc::error::RpcStatus;
|
|
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
|
use tracing::{info, warn};
|
|
|
|
use super::{require_auth, BanRecord, ModerationReport, ServerState};
|
|
|
|
/// Submit an encrypted report. Any authenticated user can report.
|
|
pub async fn handle_report_message(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
|
let identity_key = match require_auth(&state, &ctx) {
|
|
Ok(ik) => ik,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let req = match v1::ReportMessageRequest::decode(ctx.payload) {
|
|
Ok(r) => r,
|
|
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
|
};
|
|
|
|
if req.encrypted_report.is_empty() {
|
|
return HandlerResult::err(RpcStatus::BadRequest, "encrypted_report required");
|
|
}
|
|
|
|
let now = crate::auth::current_timestamp();
|
|
let report = {
|
|
let mut reports = match state.moderation_reports.lock() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
warn!("moderation_reports lock poisoned: {e}");
|
|
return HandlerResult::err(RpcStatus::Internal, "internal error");
|
|
}
|
|
};
|
|
let id = reports.len() as u64;
|
|
let report = ModerationReport {
|
|
id,
|
|
encrypted_report: req.encrypted_report,
|
|
conversation_id: req.conversation_id,
|
|
reporter_identity: identity_key.clone(),
|
|
timestamp: now,
|
|
};
|
|
reports.push(report.clone());
|
|
report
|
|
};
|
|
|
|
info!(
|
|
report_id = report.id,
|
|
reporter = hex::encode(&identity_key[..4.min(identity_key.len())]),
|
|
"moderation report submitted"
|
|
);
|
|
|
|
let proto = v1::ReportMessageResponse { accepted: true };
|
|
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
|
}
|
|
|
|
/// Ban a user. Requires admin role (currently: any authenticated user for MVP).
|
|
pub async fn handle_ban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
|
let admin_key = match require_auth(&state, &ctx) {
|
|
Ok(ik) => ik,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let req = match v1::BanUserRequest::decode(ctx.payload) {
|
|
Ok(r) => r,
|
|
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
|
};
|
|
|
|
if req.identity_key.is_empty() || req.identity_key.len() != 32 {
|
|
return HandlerResult::err(RpcStatus::BadRequest, "identity_key must be 32 bytes");
|
|
}
|
|
|
|
let now = crate::auth::current_timestamp();
|
|
let expires_at = if req.duration_secs == 0 {
|
|
0 // permanent
|
|
} else {
|
|
now + req.duration_secs
|
|
};
|
|
|
|
let record = BanRecord {
|
|
reason: req.reason.clone(),
|
|
banned_at: now,
|
|
expires_at,
|
|
};
|
|
state.banned_users.insert(req.identity_key.clone(), record);
|
|
|
|
info!(
|
|
target_key = hex::encode(&req.identity_key[..4]),
|
|
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
|
|
reason = %req.reason,
|
|
duration_secs = req.duration_secs,
|
|
"user banned"
|
|
);
|
|
|
|
let proto = v1::BanUserResponse { success: true };
|
|
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
|
}
|
|
|
|
/// Unban a user. Requires admin role.
|
|
pub async fn handle_unban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
|
let admin_key = match require_auth(&state, &ctx) {
|
|
Ok(ik) => ik,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let req = match v1::UnbanUserRequest::decode(ctx.payload) {
|
|
Ok(r) => r,
|
|
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
|
};
|
|
|
|
if req.identity_key.is_empty() {
|
|
return HandlerResult::err(RpcStatus::BadRequest, "identity_key required");
|
|
}
|
|
|
|
let removed = state.banned_users.remove(&req.identity_key).is_some();
|
|
|
|
info!(
|
|
target_key = hex::encode(&req.identity_key[..4.min(req.identity_key.len())]),
|
|
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
|
|
removed,
|
|
"user unbanned"
|
|
);
|
|
|
|
let proto = v1::UnbanUserResponse { success: removed };
|
|
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
|
}
|
|
|
|
/// List moderation reports. Requires admin role.
|
|
pub async fn handle_list_reports(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
|
let _admin_key = match require_auth(&state, &ctx) {
|
|
Ok(ik) => ik,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let req = match v1::ListReportsRequest::decode(ctx.payload) {
|
|
Ok(r) => r,
|
|
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
|
};
|
|
|
|
let reports = match state.moderation_reports.lock() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
warn!("moderation_reports lock poisoned: {e}");
|
|
return HandlerResult::err(RpcStatus::Internal, "internal error");
|
|
}
|
|
};
|
|
|
|
let offset = req.offset as usize;
|
|
let limit = if req.limit == 0 { 50 } else { req.limit as usize };
|
|
|
|
let entries: Vec<v1::ReportEntry> = reports
|
|
.iter()
|
|
.skip(offset)
|
|
.take(limit)
|
|
.map(|r| v1::ReportEntry {
|
|
id: r.id,
|
|
encrypted_report: r.encrypted_report.clone(),
|
|
conversation_id: r.conversation_id.clone(),
|
|
reporter_identity: r.reporter_identity.clone(),
|
|
timestamp: r.timestamp,
|
|
})
|
|
.collect();
|
|
|
|
let proto = v1::ListReportsResponse { reports: entries };
|
|
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
|
}
|
|
|
|
/// List banned users.
|
|
pub async fn handle_list_banned(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
|
let _admin_key = match require_auth(&state, &ctx) {
|
|
Ok(ik) => ik,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let _req = match v1::ListBannedRequest::decode(ctx.payload) {
|
|
Ok(r) => r,
|
|
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
|
};
|
|
|
|
let now = crate::auth::current_timestamp();
|
|
let entries: Vec<v1::BannedUserEntry> = state
|
|
.banned_users
|
|
.iter()
|
|
.filter(|entry| entry.expires_at == 0 || entry.expires_at > now)
|
|
.map(|entry| v1::BannedUserEntry {
|
|
identity_key: entry.key().clone(),
|
|
reason: entry.reason.clone(),
|
|
banned_at: entry.banned_at,
|
|
expires_at: entry.expires_at,
|
|
})
|
|
.collect();
|
|
|
|
let proto = v1::ListBannedResponse { users: entries };
|
|
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
|
}
|