//! 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, 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, 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, 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, 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 = 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, 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 = 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())) }