Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,178 @@ async fn synthesize_presence(
Some(events)
}

// ── Moderation queue reads (L6 — Quinn) ───────────────────────────────────────
//
// Mod-only structured rows (`moderation_reports`/`moderation_actions`/
// `community_bans`) are not nostr events, so they are served over dedicated
// NIP-98-authed GET endpoints rather than the REQ/`/query` path (which would
// force a synthetic event shape and thread a privileged branch onto the shared
// read hot path). Gated on `ModerationAction::ViewQueue` via the one capability
// helper — never an inline role check. Host-scoped: community from the request
// host, no channel context (queue reads are community-wide).

/// Shared prelude for a moderation read: bind tenant, verify NIP-98 GET auth,
/// replay-check, and confirm the caller may view the queue.
async fn authorize_moderation_read(
state: &Arc<AppState>,
headers: &HeaderMap,
path: &str,
) -> Result<TenantContext, (StatusCode, Json<Value>)> {
let raw_host = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
.await
.map_err(|_| {
api_error(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, path);
let (pubkey, event_id_bytes) =
verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
check_nip98_replay(state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();

crate::handlers::moderation_authz::authorize_moderation_action(
&tenant,
state,
&pubkey_bytes,
None,
crate::handlers::moderation_authz::ModerationTarget::None,
crate::handlers::moderation_authz::ModerationAction::ViewQueue,
)
.await
.map_err(|_| {
api_error(
StatusCode::FORBIDDEN,
"restricted: moderator access required",
)
})?;

Ok(tenant)
}

/// Cap on rows returned by a single moderation read.
const MODERATION_READ_LIMIT: i64 = 500;

/// Optional `?status=` and `?limit=` query for moderation reads.
#[derive(serde::Deserialize, Default)]
pub struct ModerationReadQuery {
status: Option<String>,
limit: Option<i64>,
}

fn clamp_limit(requested: Option<i64>) -> i64 {
requested
.filter(|n| *n > 0)
.map(|n| n.min(MODERATION_READ_LIMIT))
.unwrap_or(MODERATION_READ_LIMIT)
}

/// `GET /moderation/reports` — the moderation queue (NIP-98 + mod-authz).
pub async fn moderation_reports(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<ModerationReadQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant = authorize_moderation_read(&state, &headers, "/moderation/reports").await?;
let rows = state
.db
.list_moderation_reports(
tenant.community(),
q.status.as_deref(),
clamp_limit(q.limit),
)
.await
.map_err(|e| internal_error(&format!("list reports: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(report_json).collect())))
}

/// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz).
pub async fn moderation_audit(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(q): Query<ModerationReadQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant = authorize_moderation_read(&state, &headers, "/moderation/audit").await?;
let rows = state
.db
.list_moderation_actions(tenant.community(), clamp_limit(q.limit))
.await
.map_err(|e| internal_error(&format!("list actions: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(action_json).collect())))
}

/// `GET /moderation/restricted` — currently banned/timed-out members.
pub async fn moderation_restricted(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant = authorize_moderation_read(&state, &headers, "/moderation/restricted").await?;
let rows = state
.db
.list_community_restrictions(tenant.community())
.await
.map_err(|e| internal_error(&format!("list restrictions: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(ban_json).collect())))
}

fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value {
let (target_kind, target) = match &r.target {
buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id)),
buzz_db::moderation::ReportTarget::Pubkey(pk) => ("pubkey", hex::encode(pk)),
buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha)),
};
serde_json::json!({
"id": r.id,
"report_event_id": hex::encode(&r.report_event_id),
"reporter_pubkey": hex::encode(&r.reporter_pubkey),
"target_kind": target_kind,
"target": target,
"channel_id": r.channel_id,
"report_type": r.report_type,
"note": r.note,
"status": r.status,
"resolved_by": r.resolved_by.as_ref().map(hex::encode),
"resolved_at": r.resolved_at,
"action_id": r.action_id,
"created_at": r.created_at,
})
}

fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value {
serde_json::json!({
"id": a.id,
"actor_pubkey": hex::encode(&a.actor_pubkey),
"action": a.action,
"target_pubkey": a.target_pubkey.as_ref().map(hex::encode),
"target_event_id": a.target_event_id.as_ref().map(hex::encode),
"channel_id": a.channel_id,
"reason_code": a.reason_code,
"public_reason": a.public_reason,
"private_reason": a.private_reason,
"matched_principal": a.matched_principal,
"created_at": a.created_at,
})
}

fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value {
serde_json::json!({
"pubkey": hex::encode(&b.pubkey),
"banned": b.banned,
"ban_expires_at": b.ban_expires_at,
"ban_reason": b.ban_reason,
"muted_until": b.muted_until,
"mute_reason": b.mute_reason,
"actor_pubkey": hex::encode(&b.actor_pubkey),
"updated_at": b.updated_at,
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading