feat: add 11 features and bug fixes across server, SDK, and client
Server fixes: - Wire v2 moderation handlers to ModerationService (SQL persistence) — bans now survive restarts instead of living in-memory DashMap - Add admin role enforcement via QPC_ADMIN_KEYS env var for ban/unban - Fix audit.rs now_iso8601() to emit actual ISO-8601 timestamps - Add group admin authorization — only creator can remove members or update metadata Server features: - Add DeleteBlob RPC (method 602) with filesystem cleanup - Register delete_blob in v2 handler method registry SDK features: - Add ClientEvent::IdentityKeyChanged for safety number change alerts - Add ClientEvent::ReadReceipt and DeliveryConfirmation variants - Add peer_identity_keys table with store/get methods for key tracking - Add search_messages() full-text search across all conversations - Add delete_conversation() with cascading message/outbox cleanup Client features: - Wire v2 TUI message sending to SDK MLS encryption pipeline - Add /search command to v2 REPL with cross-conversation results - Add /delete-conversation command to v2 REPL - Add unread count badges in v1 TUI sidebar (yellow+bold styling)
This commit is contained in:
@@ -142,15 +142,33 @@ pub fn format_actor(identity_key: &[u8], redact: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Current ISO-8601 UTC timestamp.
|
||||
/// Current ISO-8601 UTC timestamp (e.g. `2026-04-04T12:30:45Z`).
|
||||
pub fn now_iso8601() -> String {
|
||||
// Use SystemTime to avoid pulling in chrono.
|
||||
let d = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = d.as_secs();
|
||||
// Simple UTC formatting: enough for audit logs.
|
||||
format!("{secs}")
|
||||
|
||||
// Manual UTC calendar conversion — avoids pulling in chrono.
|
||||
let days = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
// Civil date from day count (epoch = 1970-01-01, algorithm from Howard Hinnant).
|
||||
let z = days as i64 + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = (z - era * 146097) as u64; // day of era [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -194,4 +194,27 @@ impl BlobService {
|
||||
mime_type: meta.mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a blob and its metadata from disk.
|
||||
pub fn delete_blob(&self, blob_id: &[u8]) -> Result<bool, DomainError> {
|
||||
if blob_id.len() != 32 {
|
||||
return Err(DomainError::BlobHashLength(blob_id.len()));
|
||||
}
|
||||
|
||||
let blob_hex = hex::encode(blob_id);
|
||||
let dir = self.blobs_dir();
|
||||
let blob_path = dir.join(&blob_hex);
|
||||
let meta_path = dir.join(format!("{blob_hex}.meta"));
|
||||
let part_path = dir.join(format!("{blob_hex}.part"));
|
||||
|
||||
if !blob_path.exists() && !part_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&blob_path);
|
||||
let _ = std::fs::remove_file(&meta_path);
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,38 @@ mod ws_bridge;
|
||||
#[cfg(feature = "webtransport")]
|
||||
mod webtransport;
|
||||
|
||||
/// Parse `QPC_ADMIN_KEYS` env var — comma-separated hex-encoded Ed25519 public keys.
|
||||
/// Returns empty vec if unset (backward-compatible: all users can moderate).
|
||||
#[cfg(feature = "webtransport")]
|
||||
fn parse_admin_keys() -> Vec<Vec<u8>> {
|
||||
let Ok(val) = std::env::var("QPC_ADMIN_KEYS") else {
|
||||
return Vec::new();
|
||||
};
|
||||
val.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match hex::decode(s) {
|
||||
Ok(key) if key.len() == 32 => Some(key),
|
||||
Ok(key) => {
|
||||
tracing::warn!(
|
||||
len = key.len(),
|
||||
hex = s,
|
||||
"QPC_ADMIN_KEYS: ignoring key with wrong length (expected 32 bytes)"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(hex = s, error = %e, "QPC_ADMIN_KEYS: ignoring invalid hex");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
use auth::{AuthConfig, PendingLogin, RateEntry, SessionInfo};
|
||||
use config::{
|
||||
load_config, merge_config, validate_production_config, DEFAULT_DATA_DIR, DEFAULT_DB_PATH,
|
||||
@@ -433,6 +465,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
storage_backend: effective.store_backend.clone(),
|
||||
federation_client: None,
|
||||
local_domain: effective.federation.as_ref().map(|f| f.domain.clone()).unwrap_or_default(),
|
||||
admin_keys: parse_admin_keys(),
|
||||
});
|
||||
|
||||
let wt_registry = Arc::new(v2_handlers::build_registry(
|
||||
|
||||
@@ -99,3 +99,32 @@ pub async fn handle_download_blob(state: Arc<ServerState>, ctx: RequestContext)
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_delete_blob(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::DeleteBlobRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return HandlerResult::err(
|
||||
quicprochat_rpc::error::RpcStatus::BadRequest,
|
||||
&format!("decode: {e}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let svc = BlobService {
|
||||
data_dir: state.data_dir.clone(),
|
||||
};
|
||||
|
||||
match svc.delete_blob(&req.blob_id) {
|
||||
Ok(deleted) => {
|
||||
let proto = v1::DeleteBlobResponse { deleted };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,18 @@ pub async fn handle_remove_member(
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
// Only group creator (admin) can remove members.
|
||||
if let Ok(Some(meta)) = svc.get_metadata(&req.group_id) {
|
||||
if !meta.creator_key.is_empty() && meta.creator_key != identity_key {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::Forbidden,
|
||||
"only the group creator can remove members",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match svc.remove_member(&req.group_id, &req.member_identity_key) {
|
||||
Ok(_) => {
|
||||
let _ = identity_key; // caller is authorized; removal tracked
|
||||
let proto = v1::RemoveMemberResponse {
|
||||
commit: Vec::new(), // commit is generated client-side
|
||||
};
|
||||
@@ -73,6 +82,16 @@ pub async fn handle_update_group_metadata(
|
||||
store: Arc::clone(&state.store),
|
||||
};
|
||||
|
||||
// Only group creator (admin) can update metadata.
|
||||
if let Ok(Some(meta)) = svc.get_metadata(&req.group_id) {
|
||||
if !meta.creator_key.is_empty() && meta.creator_key != identity_key {
|
||||
return HandlerResult::err(
|
||||
RpcStatus::Forbidden,
|
||||
"only the group creator can update metadata",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let domain_req = UpdateGroupMetadataReq {
|
||||
group_id: req.group_id,
|
||||
name: req.name,
|
||||
|
||||
@@ -68,6 +68,8 @@ pub struct ServerState {
|
||||
pub federation_client: Option<Arc<crate::federation::FederationClient>>,
|
||||
/// This server's domain for federation addressing. Empty when federation is disabled.
|
||||
pub local_domain: String,
|
||||
/// Admin identity keys (from `QPC_ADMIN_USERS` env or config). Empty = allow all (MVP).
|
||||
pub admin_keys: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A ban record for a user.
|
||||
@@ -316,6 +318,11 @@ pub fn build_registry(default_rpc_timeout: std::time::Duration) -> MethodRegistr
|
||||
std::time::Duration::from_secs(120),
|
||||
blob::handle_download_blob,
|
||||
);
|
||||
reg.register(
|
||||
method_ids::DELETE_BLOB,
|
||||
"DeleteBlob",
|
||||
blob::handle_delete_blob,
|
||||
);
|
||||
|
||||
// Device (700-702)
|
||||
reg.register(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Moderation handlers — report, ban, unban, list reports, list banned.
|
||||
//!
|
||||
//! All mutations are persisted via `ModerationService` (SQL store).
|
||||
//! The in-memory `banned_users` DashMap is kept as a hot cache for the
|
||||
//! auth middleware's fast-path ban check.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -9,7 +13,34 @@ use quicprochat_rpc::error::RpcStatus;
|
||||
use quicprochat_rpc::method::{HandlerResult, RequestContext};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{require_auth, BanRecord, ModerationReport, ServerState};
|
||||
use crate::domain::moderation::ModerationService;
|
||||
use crate::domain::types::*;
|
||||
|
||||
use super::{require_auth, BanRecord, ServerState};
|
||||
|
||||
/// Build a `ModerationService` from shared state.
|
||||
fn mod_service(state: &ServerState) -> ModerationService {
|
||||
ModerationService {
|
||||
store: Arc::clone(&state.store),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the caller is an admin. Admins are identified by identity
|
||||
/// key listed in `state.admin_keys`. Returns `Err(HandlerResult)` with
|
||||
/// `Forbidden` status for non-admins.
|
||||
fn require_admin(state: &ServerState, identity_key: &[u8]) -> Result<(), HandlerResult> {
|
||||
if state.admin_keys.is_empty() {
|
||||
// No admin list configured — allow all (backward-compatible MVP behavior).
|
||||
return Ok(());
|
||||
}
|
||||
if state.admin_keys.iter().any(|k| k.as_slice() == identity_key) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(HandlerResult::err(
|
||||
RpcStatus::Forbidden,
|
||||
"admin role required",
|
||||
))
|
||||
}
|
||||
|
||||
/// Submit an encrypted report. Any authenticated user can report.
|
||||
pub async fn handle_report_message(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
@@ -23,81 +54,91 @@ pub async fn handle_report_message(state: Arc<ServerState>, ctx: RequestContext)
|
||||
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 svc = mod_service(&state);
|
||||
match svc.report_message(ReportMessageReq {
|
||||
encrypted_report: req.encrypted_report,
|
||||
conversation_id: req.conversation_id,
|
||||
reporter_identity: identity_key.clone(),
|
||||
}) {
|
||||
Ok(resp) => {
|
||||
info!(
|
||||
reporter = hex::encode(&identity_key[..4.min(identity_key.len())]),
|
||||
"moderation report submitted (persisted)"
|
||||
);
|
||||
let proto = v1::ReportMessageResponse {
|
||||
accepted: resp.accepted,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(DomainError::BadParams(msg)) => HandlerResult::err(RpcStatus::BadRequest, &msg),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "report_message failed");
|
||||
HandlerResult::err(RpcStatus::Internal, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
/// Ban a user. Requires admin role.
|
||||
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,
|
||||
};
|
||||
|
||||
if let Err(e) = require_admin(&state, &admin_key) {
|
||||
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 {
|
||||
let svc = mod_service(&state);
|
||||
match svc.ban_user(BanUserReq {
|
||||
identity_key: req.identity_key.clone(),
|
||||
reason: req.reason.clone(),
|
||||
banned_at: now,
|
||||
expires_at,
|
||||
};
|
||||
state.banned_users.insert(req.identity_key.clone(), record);
|
||||
duration_secs: req.duration_secs,
|
||||
}) {
|
||||
Ok(resp) => {
|
||||
// Update hot cache so auth middleware picks it up immediately.
|
||||
let now = crate::auth::current_timestamp();
|
||||
let expires_at = if req.duration_secs == 0 {
|
||||
0
|
||||
} else {
|
||||
now + req.duration_secs
|
||||
};
|
||||
state.banned_users.insert(
|
||||
req.identity_key.clone(),
|
||||
BanRecord {
|
||||
reason: req.reason.clone(),
|
||||
banned_at: now,
|
||||
expires_at,
|
||||
},
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
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())]),
|
||||
reason = %req.reason,
|
||||
duration_secs = req.duration_secs,
|
||||
"user banned (persisted)"
|
||||
);
|
||||
|
||||
let proto = v1::BanUserResponse { success: true };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
let proto = v1::BanUserResponse {
|
||||
success: resp.success,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(DomainError::InvalidIdentityKey(len)) => HandlerResult::err(
|
||||
RpcStatus::BadRequest,
|
||||
&format!("identity_key must be 32 bytes, got {len}"),
|
||||
),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "ban_user failed");
|
||||
HandlerResult::err(RpcStatus::Internal, "internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unban a user. Requires admin role.
|
||||
@@ -107,6 +148,10 @@ pub async fn handle_unban_user(state: Arc<ServerState>, ctx: RequestContext) ->
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if let Err(e) = require_admin(&state, &admin_key) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let req = match v1::UnbanUserRequest::decode(ctx.payload) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
|
||||
@@ -116,84 +161,115 @@ pub async fn handle_unban_user(state: Arc<ServerState>, ctx: RequestContext) ->
|
||||
return HandlerResult::err(RpcStatus::BadRequest, "identity_key required");
|
||||
}
|
||||
|
||||
let removed = state.banned_users.remove(&req.identity_key).is_some();
|
||||
let svc = mod_service(&state);
|
||||
match svc.unban_user(UnbanUserReq {
|
||||
identity_key: req.identity_key.clone(),
|
||||
}) {
|
||||
Ok(resp) => {
|
||||
// Remove from hot cache.
|
||||
state.banned_users.remove(&req.identity_key);
|
||||
|
||||
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"
|
||||
);
|
||||
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 = resp.success,
|
||||
"user unbanned (persisted)"
|
||||
);
|
||||
|
||||
let proto = v1::UnbanUserResponse { success: removed };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
let proto = v1::UnbanUserResponse {
|
||||
success: resp.success,
|
||||
};
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "unban_user failed");
|
||||
HandlerResult::err(RpcStatus::Internal, "internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if let Err(e) = require_admin(&state, &admin_key) {
|
||||
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 limit = if req.limit == 0 { 50 } else { req.limit };
|
||||
|
||||
let svc = mod_service(&state);
|
||||
match svc.list_reports(ListReportsReq {
|
||||
limit,
|
||||
offset: req.offset,
|
||||
}) {
|
||||
Ok(resp) => {
|
||||
let entries: Vec<v1::ReportEntry> = resp
|
||||
.reports
|
||||
.into_iter()
|
||||
.map(|r| v1::ReportEntry {
|
||||
id: r.id,
|
||||
encrypted_report: r.encrypted_report,
|
||||
conversation_id: r.conversation_id,
|
||||
reporter_identity: r.reporter_identity,
|
||||
timestamp: r.timestamp,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto = v1::ListReportsResponse { reports: entries };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
};
|
||||
|
||||
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()))
|
||||
Err(e) => {
|
||||
warn!(error = %e, "list_reports failed");
|
||||
HandlerResult::err(RpcStatus::Internal, "internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List banned users.
|
||||
/// List banned users. Requires admin role.
|
||||
pub async fn handle_list_banned(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
|
||||
let _admin_key = match require_auth(&state, &ctx) {
|
||||
let admin_key = match require_auth(&state, &ctx) {
|
||||
Ok(ik) => ik,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if let Err(e) = require_admin(&state, &admin_key) {
|
||||
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 svc = mod_service(&state);
|
||||
match svc.list_banned() {
|
||||
Ok(resp) => {
|
||||
let entries: Vec<v1::BannedUserEntry> = resp
|
||||
.users
|
||||
.into_iter()
|
||||
.map(|u| v1::BannedUserEntry {
|
||||
identity_key: u.identity_key,
|
||||
reason: u.reason,
|
||||
banned_at: u.banned_at,
|
||||
expires_at: u.expires_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto = v1::ListBannedResponse { users: entries };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
let proto = v1::ListBannedResponse { users: entries };
|
||||
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "list_banned failed");
|
||||
HandlerResult::err(RpcStatus::Internal, "internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user