From ded31d2fb573591e63ca61fa8a9d07faab1c5fda Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 14:16:28 -0400 Subject: [PATCH] feat(db): port moderation to SQLite Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 165 +++++++++++---- crates/buzz-db/src/sqlite.rs | 390 ++++++++++++++++++++++++++++++++++- 2 files changed, 514 insertions(+), 41 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 6a15368bc0..5a22f16849 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4981,7 +4981,14 @@ impl Db { community: CommunityId, report: moderation::NewReport<'_>, ) -> Result { - moderation::insert_report(self.pg_pool()?, community, report).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::insert_moderation_report(pool, community, report).await + } + DbBackend::Postgres => { + moderation::insert_report(self.pg_pool()?, community, report).await + } + } } /// List moderation reports for a community, newest first. @@ -4991,7 +4998,14 @@ impl Db { status: Option<&str>, limit: i64, ) -> Result> { - moderation::list_reports(self.pg_pool()?, community, status, limit).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::list_moderation_reports(pool, community, status, limit).await + } + DbBackend::Postgres => { + moderation::list_reports(self.pg_pool()?, community, status, limit).await + } + } } /// Fetch one moderation report by row id. @@ -5000,7 +5014,14 @@ impl Db { community: CommunityId, report_id: Uuid, ) -> Result> { - moderation::get_report(self.pg_pool()?, community, report_id).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::get_moderation_report(pool, community, report_id).await + } + DbBackend::Postgres => { + moderation::get_report(self.pg_pool()?, community, report_id).await + } + } } /// Fetch one moderation report by signed NIP-56 report event id. @@ -5009,7 +5030,14 @@ impl Db { community: CommunityId, report_event_id: &[u8], ) -> Result> { - moderation::get_report_by_event(self.pg_pool()?, community, report_event_id).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::get_moderation_report_by_event(pool, community, report_event_id).await + } + DbBackend::Postgres => { + moderation::get_report_by_event(self.pg_pool()?, community, report_event_id).await + } + } } /// Resolve, dismiss, or escalate an open moderation report. @@ -5021,15 +5049,30 @@ impl Db { resolved_by: &[u8], action_id: Option, ) -> Result { - moderation::resolve_report( - self.pg_pool()?, - community, - report_id, - status, - resolved_by, - action_id, - ) - .await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::resolve_moderation_report( + pool, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + DbBackend::Postgres => { + moderation::resolve_report( + self.pg_pool()?, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + } } /// Upsert a community ban for a member pubkey. @@ -5041,15 +5084,22 @@ impl Db { reason: Option<&str>, expires_at: Option>, ) -> Result<()> { - moderation::ban_member( - self.pg_pool()?, - community, - pubkey, - actor, - reason, - expires_at, - ) - .await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::ban_member(pool, community, pubkey, actor, reason, expires_at).await + } + DbBackend::Postgres => { + moderation::ban_member( + self.pg_pool()?, + community, + pubkey, + actor, + reason, + expires_at, + ) + .await + } + } } /// Lift a community ban for a member pubkey. @@ -5059,7 +5109,12 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::unban_member(self.pg_pool()?, community, pubkey, actor).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::unban_member(pool, community, pubkey, actor).await, + DbBackend::Postgres => { + moderation::unban_member(self.pg_pool()?, community, pubkey, actor).await + } + } } /// Upsert a community timeout/write-block for a member pubkey. @@ -5071,15 +5126,22 @@ impl Db { muted_until: DateTime, reason: Option<&str>, ) -> Result<()> { - moderation::timeout_member( - self.pg_pool()?, - community, - pubkey, - actor, - muted_until, - reason, - ) - .await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::timeout_member(pool, community, pubkey, actor, muted_until, reason).await + } + DbBackend::Postgres => { + moderation::timeout_member( + self.pg_pool()?, + community, + pubkey, + actor, + muted_until, + reason, + ) + .await + } + } } /// Clear a community timeout/write-block for a member pubkey. @@ -5089,7 +5151,14 @@ impl Db { pubkey: &[u8], actor: &[u8], ) -> Result { - moderation::untimeout_member(self.pg_pool()?, community, pubkey, actor).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::untimeout_member(pool, community, pubkey, actor).await + } + DbBackend::Postgres => { + moderation::untimeout_member(self.pg_pool()?, community, pubkey, actor).await + } + } } /// Fetch the active ban/timeout restriction state for enforcement hot paths. @@ -5099,7 +5168,7 @@ impl Db { pubkey: &[u8], ) -> Result { match &self.backend { - DbBackend::SQLite(_) => Ok(moderation::RestrictionState::default()), + DbBackend::SQLite(pool) => sqlite::restriction_state(pool, community, pubkey).await, DbBackend::Postgres => { moderation::restriction_state(self.pg_pool()?, community, pubkey).await } @@ -5112,7 +5181,10 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result> { - moderation::get_ban(self.pg_pool()?, community, pubkey).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::get_ban(pool, community, pubkey).await, + DbBackend::Postgres => moderation::get_ban(self.pg_pool()?, community, pubkey).await, + } } /// List currently restricted members in a community. @@ -5120,7 +5192,10 @@ impl Db { &self, community: CommunityId, ) -> Result> { - moderation::list_restricted(self.pg_pool()?, community).await + match &self.backend { + DbBackend::SQLite(pool) => sqlite::list_restricted(pool, community).await, + DbBackend::Postgres => moderation::list_restricted(self.pg_pool()?, community).await, + } } /// Insert a moderation audit action row. @@ -5129,7 +5204,14 @@ impl Db { community: CommunityId, action: moderation::NewAction<'_>, ) -> Result { - moderation::insert_action(self.pg_pool()?, community, action).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::insert_moderation_action(pool, community, action).await + } + DbBackend::Postgres => { + moderation::insert_action(self.pg_pool()?, community, action).await + } + } } /// List moderation audit action rows, newest first. @@ -5138,7 +5220,14 @@ impl Db { community: CommunityId, limit: i64, ) -> Result> { - moderation::list_actions(self.pg_pool()?, community, limit).await + match &self.backend { + DbBackend::SQLite(pool) => { + sqlite::list_moderation_actions(pool, community, limit).await + } + DbBackend::Postgres => { + moderation::list_actions(self.pg_pool()?, community, limit).await + } + } } /// Return the current owner of git repo name `repo_id` in `community`, or diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index d6dd1ec524..42708da75f 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -317,7 +317,26 @@ pub(crate) async fn migrate(pool: &SqlitePool) -> Result<()> { tx.commit().await?; version = 9; } - if version != 9 { + if version < 10 { + let mut tx = pool.begin().await?; + for statement in [ + "CREATE TABLE IF NOT EXISTS community_bans (community_id TEXT NOT NULL, pubkey BLOB NOT NULL CHECK (length(pubkey) = 32), banned INTEGER NOT NULL DEFAULT 0 CHECK (banned IN (0, 1)), ban_expires_at INTEGER, ban_reason TEXT, muted_until INTEGER, mute_reason TEXT, actor_pubkey BLOB NOT NULL CHECK (length(actor_pubkey) = 32), created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (community_id, pubkey), FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE)", + "CREATE TABLE IF NOT EXISTS moderation_actions (community_id TEXT NOT NULL, id TEXT NOT NULL, actor_pubkey BLOB NOT NULL CHECK (length(actor_pubkey) = 32), action TEXT NOT NULL CHECK (action IN ('delete_message', 'kick', 'ban', 'unban', 'timeout', 'untimeout', 'dismiss_report', 'escalate', 'resolve:delete', 'resolve:kick', 'resolve:ban', 'resolve:timeout')), target_pubkey BLOB CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32), target_event_id BLOB CHECK (target_event_id IS NULL OR length(target_event_id) = 32), channel_id TEXT, reason_code TEXT, public_reason TEXT, private_reason TEXT, matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')), created_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (community_id, id), FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE, FOREIGN KEY (channel_id) REFERENCES channels(id))", + "CREATE INDEX IF NOT EXISTS idx_moderation_actions_created ON moderation_actions (community_id, created_at DESC, id DESC)", + "CREATE INDEX IF NOT EXISTS idx_moderation_actions_target_pubkey ON moderation_actions (community_id, target_pubkey) WHERE target_pubkey IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_community_bans_active ON community_bans (community_id, updated_at DESC)", + "CREATE TRIGGER IF NOT EXISTS moderation_reports_action_tenant_insert BEFORE INSERT ON moderation_reports WHEN NEW.action_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM moderation_actions WHERE community_id=NEW.community_id AND id=NEW.action_id) BEGIN SELECT RAISE(ABORT, 'moderation report action must belong to community'); END", + "CREATE TRIGGER IF NOT EXISTS moderation_reports_action_tenant_update BEFORE UPDATE OF action_id, community_id ON moderation_reports WHEN NEW.action_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM moderation_actions WHERE community_id=NEW.community_id AND id=NEW.action_id) BEGIN SELECT RAISE(ABORT, 'moderation report action must belong to community'); END", + "CREATE TRIGGER IF NOT EXISTS moderation_actions_channel_tenant_insert BEFORE INSERT ON moderation_actions WHEN NEW.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM channels WHERE community_id=NEW.community_id AND id=NEW.channel_id) BEGIN SELECT RAISE(ABORT, 'moderation action channel must belong to community'); END", + "CREATE TRIGGER IF NOT EXISTS moderation_actions_channel_tenant_update BEFORE UPDATE OF channel_id, community_id ON moderation_actions WHEN NEW.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM channels WHERE community_id=NEW.community_id AND id=NEW.channel_id) BEGIN SELECT RAISE(ABORT, 'moderation action channel must belong to community'); END", + ] { sqlx::query(statement).execute(&mut *tx).await?; } + sqlx::query("UPDATE schema_version SET version = 10 WHERE singleton = 1") + .execute(&mut *tx) + .await?; + tx.commit().await?; + version = 10; + } + if version != 10 { return Err(crate::DbError::InvalidData(format!( "unsupported SQLite schema version {version}" ))); @@ -1732,6 +1751,230 @@ pub(crate) async fn list_product_feedback( .collect() } +fn moderation_report_row(r: sqlx::sqlite::SqliteRow) -> Result { + let target = match r.get::("target_kind").as_str() { + "event" => crate::moderation::ReportTarget::Event(r.get("target_event_id")), + "pubkey" => crate::moderation::ReportTarget::Pubkey(r.get("target_pubkey")), + "blob" => crate::moderation::ReportTarget::Blob(r.get("target_blob_sha256")), + other => { + return Err(crate::DbError::InvalidData(format!( + "invalid report target_kind: {other}" + ))) + } + }; + Ok(crate::moderation::ReportRecord { + id: parse_uuid(r.get("id"))?, + report_event_id: r.get("report_event_id"), + reporter_pubkey: r.get("reporter_pubkey"), + target, + channel_id: optional_uuid(r.try_get("channel_id")?)?, + report_type: r.get("report_type"), + note: r.get("note"), + status: r.get("status"), + resolved_by: r.get("resolved_by"), + resolved_at: optional_timestamp(r.try_get("resolved_at")?)?, + action_id: optional_uuid(r.try_get("action_id")?)?, + created_at: timestamp(r.get("created_at"))?, + }) +} + +fn parse_uuid(value: String) -> Result { + Uuid::parse_str(&value).map_err(|e| crate::DbError::InvalidData(e.to_string())) +} +fn optional_uuid(value: Option) -> Result> { + value.map(parse_uuid).transpose() +} + +pub(crate) async fn insert_moderation_report( + pool: &SqlitePool, + community: CommunityId, + report: crate::moderation::NewReport<'_>, +) -> Result { + let id = Uuid::new_v4(); + let (kind, event, pubkey, blob) = match &report.target { + crate::moderation::ReportTarget::Event(v) => ("event", Some(v.as_slice()), None, None), + crate::moderation::ReportTarget::Pubkey(v) => ("pubkey", None, Some(v.as_slice()), None), + crate::moderation::ReportTarget::Blob(v) => ("blob", None, None, Some(v.as_slice())), + }; + sqlx::query("INSERT INTO moderation_reports (id, community_id, report_event_id, reporter_pubkey, target_kind, target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11) ON CONFLICT(community_id, report_event_id) DO NOTHING") + .bind(id.to_string()).bind(community.as_uuid().to_string()).bind(report.report_event_id).bind(report.reporter_pubkey).bind(kind).bind(event).bind(pubkey).bind(blob).bind(report.channel_id.map(|v| v.to_string())).bind(report.report_type).bind(report.note).execute(pool).await?; + parse_uuid( + sqlx::query_scalar( + "SELECT id FROM moderation_reports WHERE community_id=?1 AND report_event_id=?2", + ) + .bind(community.as_uuid().to_string()) + .bind(report.report_event_id) + .fetch_one(pool) + .await?, + ) +} + +const REPORT_COLUMNS: &str = "id, report_event_id, reporter_pubkey, target_kind, target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note, status, resolved_by, resolved_at, action_id, created_at"; +pub(crate) async fn list_moderation_reports( + pool: &SqlitePool, + community: CommunityId, + status: Option<&str>, + limit: i64, +) -> Result> { + let q = format!("SELECT {REPORT_COLUMNS} FROM moderation_reports WHERE community_id=?1 AND (?2 IS NULL OR status=?2) ORDER BY created_at DESC, id DESC LIMIT ?3"); + sqlx::query(sqlx::AssertSqlSafe(q)) + .bind(community.as_uuid().to_string()) + .bind(status) + .bind(limit) + .fetch_all(pool) + .await? + .into_iter() + .map(moderation_report_row) + .collect() +} +pub(crate) async fn get_moderation_report( + pool: &SqlitePool, + community: CommunityId, + id: Uuid, +) -> Result> { + let q = + format!("SELECT {REPORT_COLUMNS} FROM moderation_reports WHERE community_id=?1 AND id=?2"); + sqlx::query(sqlx::AssertSqlSafe(q)) + .bind(community.as_uuid().to_string()) + .bind(id.to_string()) + .fetch_optional(pool) + .await? + .map(moderation_report_row) + .transpose() +} +pub(crate) async fn get_moderation_report_by_event( + pool: &SqlitePool, + community: CommunityId, + event: &[u8], +) -> Result> { + let q=format!("SELECT {REPORT_COLUMNS} FROM moderation_reports WHERE community_id=?1 AND report_event_id=?2"); + sqlx::query(sqlx::AssertSqlSafe(q)) + .bind(community.as_uuid().to_string()) + .bind(event) + .fetch_optional(pool) + .await? + .map(moderation_report_row) + .transpose() +} +pub(crate) async fn resolve_moderation_report( + pool: &SqlitePool, + community: CommunityId, + id: Uuid, + status: &str, + actor: &[u8], + action_id: Option, +) -> Result { + Ok(sqlx::query("UPDATE moderation_reports SET status=?3,resolved_by=?4,resolved_at=unixepoch(),action_id=?5 WHERE community_id=?1 AND id=?2 AND status='open'").bind(community.as_uuid().to_string()).bind(id.to_string()).bind(status).bind(actor).bind(action_id.map(|v|v.to_string())).execute(pool).await?.rows_affected()>0) +} +pub(crate) async fn ban_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires: Option>, +) -> Result<()> { + sqlx::query("INSERT INTO community_bans (community_id,pubkey,banned,ban_expires_at,ban_reason,actor_pubkey) VALUES (?1,?2,1,?3,?4,?5) ON CONFLICT(community_id,pubkey) DO UPDATE SET banned=1,ban_expires_at=excluded.ban_expires_at,ban_reason=excluded.ban_reason,actor_pubkey=excluded.actor_pubkey,updated_at=unixepoch()").bind(community.as_uuid().to_string()).bind(pubkey).bind(expires.map(|v|v.timestamp())).bind(reason).bind(actor).execute(pool).await?; + Ok(()) +} +pub(crate) async fn unban_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + Ok(sqlx::query("UPDATE community_bans SET banned=0,ban_expires_at=NULL,ban_reason=NULL,actor_pubkey=?3,updated_at=unixepoch() WHERE community_id=?1 AND pubkey=?2 AND banned=1").bind(community.as_uuid().to_string()).bind(pubkey).bind(actor).execute(pool).await?.rows_affected()>0) +} +pub(crate) async fn timeout_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + until: chrono::DateTime, + reason: Option<&str>, +) -> Result<()> { + sqlx::query("INSERT INTO community_bans (community_id,pubkey,muted_until,mute_reason,actor_pubkey) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(community_id,pubkey) DO UPDATE SET muted_until=excluded.muted_until,mute_reason=excluded.mute_reason,actor_pubkey=excluded.actor_pubkey,updated_at=unixepoch()").bind(community.as_uuid().to_string()).bind(pubkey).bind(until.timestamp()).bind(reason).bind(actor).execute(pool).await?; + Ok(()) +} +pub(crate) async fn untimeout_member( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + Ok(sqlx::query("UPDATE community_bans SET muted_until=NULL,mute_reason=NULL,actor_pubkey=?3,updated_at=unixepoch() WHERE community_id=?1 AND pubkey=?2 AND muted_until>unixepoch()").bind(community.as_uuid().to_string()).bind(pubkey).bind(actor).execute(pool).await?.rows_affected()>0) +} +pub(crate) async fn restriction_state( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result { + let row=sqlx::query("SELECT banned AND (ban_expires_at IS NULL OR ban_expires_at>unixepoch()) AS banned, CASE WHEN muted_until>unixepoch() THEN muted_until END AS muted_until FROM community_bans WHERE community_id=?1 AND pubkey=?2").bind(community.as_uuid().to_string()).bind(pubkey).fetch_optional(pool).await?; + Ok(match row { + Some(r) => crate::moderation::RestrictionState { + banned: r.get::("banned") != 0, + muted_until: optional_timestamp(r.try_get("muted_until")?)?, + }, + None => Default::default(), + }) +} +fn ban_row(r: sqlx::sqlite::SqliteRow) -> Result { + Ok(crate::moderation::BanRecord { + pubkey: r.get("pubkey"), + banned: r.get::("banned") != 0, + ban_expires_at: optional_timestamp(r.try_get("ban_expires_at")?)?, + ban_reason: r.get("ban_reason"), + muted_until: optional_timestamp(r.try_get("muted_until")?)?, + mute_reason: r.get("mute_reason"), + actor_pubkey: r.get("actor_pubkey"), + updated_at: timestamp(r.get("updated_at"))?, + }) +} +pub(crate) async fn get_ban( + pool: &SqlitePool, + community: CommunityId, + pubkey: &[u8], +) -> Result> { + sqlx::query("SELECT pubkey,banned AND (ban_expires_at IS NULL OR ban_expires_at>unixepoch()) AS banned,ban_expires_at,ban_reason,muted_until,mute_reason,actor_pubkey,updated_at FROM community_bans WHERE community_id=?1 AND pubkey=?2").bind(community.as_uuid().to_string()).bind(pubkey).fetch_optional(pool).await?.map(ban_row).transpose() +} +pub(crate) async fn list_restricted( + pool: &SqlitePool, + community: CommunityId, +) -> Result> { + sqlx::query("SELECT pubkey,banned AND (ban_expires_at IS NULL OR ban_expires_at>unixepoch()) AS banned,ban_expires_at,ban_reason,muted_until,mute_reason,actor_pubkey,updated_at FROM community_bans WHERE community_id=?1 AND ((banned=1 AND (ban_expires_at IS NULL OR ban_expires_at>unixepoch())) OR muted_until>unixepoch()) ORDER BY updated_at DESC,pubkey").bind(community.as_uuid().to_string()).fetch_all(pool).await?.into_iter().map(ban_row).collect() +} +pub(crate) async fn insert_moderation_action( + pool: &SqlitePool, + community: CommunityId, + a: crate::moderation::NewAction<'_>, +) -> Result { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO moderation_actions (community_id,id,actor_pubkey,action,target_pubkey,target_event_id,channel_id,reason_code,public_reason,private_reason,matched_principal) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)").bind(community.as_uuid().to_string()).bind(id.to_string()).bind(a.actor_pubkey).bind(a.action).bind(a.target_pubkey).bind(a.target_event_id).bind(a.channel_id.map(|v|v.to_string())).bind(a.reason_code).bind(a.public_reason).bind(a.private_reason).bind(a.matched_principal).execute(pool).await?; + Ok(id) +} +fn action_row(r: sqlx::sqlite::SqliteRow) -> Result { + Ok(crate::moderation::ActionRecord { + id: parse_uuid(r.get("id"))?, + actor_pubkey: r.get("actor_pubkey"), + action: r.get("action"), + target_pubkey: r.get("target_pubkey"), + target_event_id: r.get("target_event_id"), + channel_id: optional_uuid(r.try_get("channel_id")?)?, + reason_code: r.get("reason_code"), + public_reason: r.get("public_reason"), + private_reason: r.get("private_reason"), + matched_principal: r.get("matched_principal"), + created_at: timestamp(r.get("created_at"))?, + }) +} +pub(crate) async fn list_moderation_actions( + pool: &SqlitePool, + community: CommunityId, + limit: i64, +) -> Result> { + sqlx::query("SELECT id,actor_pubkey,action,target_pubkey,target_event_id,channel_id,reason_code,public_reason,private_reason,matched_principal,created_at FROM moderation_actions WHERE community_id=?1 ORDER BY created_at DESC,id DESC LIMIT ?2").bind(community.as_uuid().to_string()).bind(limit).fetch_all(pool).await?.into_iter().map(action_row).collect() +} + pub(crate) async fn admin_list_feedback( pool: &SqlitePool, limit: i64, @@ -2552,7 +2795,7 @@ mod tests { .fetch_one(&upgraded) .await .unwrap(), - 9 + 10 ); for (table, column) in [ ("channels", "participant_hash"), @@ -2679,7 +2922,7 @@ mod tests { .fetch_one(&upgraded) .await .unwrap(), - 9 + 10 ); assert_eq!( sqlx::query_scalar::<_, i64>( @@ -3192,6 +3435,147 @@ mod tests { assert_eq!(message.deleted_at, None); } + #[tokio::test] + async fn moderation_mutations_restrictions_and_audit_are_scoped() { + let pool = connect(":memory:").await.unwrap(); + let community = ensure_configured_community(&pool, "moderation.local") + .await + .unwrap() + .id; + let foreign = ensure_configured_community(&pool, "moderation-foreign.local") + .await + .unwrap() + .id; + let report_event = vec![41_u8; 32]; + let reporter = vec![42_u8; 32]; + let target = vec![43_u8; 32]; + let actor = vec![44_u8; 32]; + let report = || crate::moderation::NewReport { + report_event_id: &report_event, + reporter_pubkey: &reporter, + target: crate::moderation::ReportTarget::Event(target.clone()), + channel_id: None, + report_type: "spam", + note: Some("first note"), + }; + let id = insert_moderation_report(&pool, community, report()) + .await + .unwrap(); + assert_eq!( + insert_moderation_report(&pool, community, report()) + .await + .unwrap(), + id + ); + assert_eq!( + list_moderation_reports(&pool, community, Some("open"), 10) + .await + .unwrap() + .len(), + 1 + ); + assert!( + get_moderation_report_by_event(&pool, foreign, &report_event) + .await + .unwrap() + .is_none() + ); + + let action_id = insert_moderation_action( + &pool, + community, + crate::moderation::NewAction { + actor_pubkey: &actor, + action: "resolve:ban", + target_pubkey: Some(&target), + target_event_id: None, + channel_id: None, + reason_code: Some("spam"), + public_reason: None, + private_reason: Some("audit context"), + matched_principal: Some("self"), + }, + ) + .await + .unwrap(); + assert!(resolve_moderation_report( + &pool, + community, + id, + "resolved", + &actor, + Some(action_id) + ) + .await + .unwrap()); + assert!( + !resolve_moderation_report(&pool, community, id, "dismissed", &actor, None) + .await + .unwrap() + ); + assert_eq!( + get_moderation_report(&pool, community, id) + .await + .unwrap() + .unwrap() + .status, + "resolved" + ); + + let expired = chrono::Utc::now() - chrono::Duration::seconds(10); + let future = chrono::Utc::now() + chrono::Duration::hours(1); + ban_member( + &pool, + community, + &target, + &actor, + Some("expired"), + Some(expired), + ) + .await + .unwrap(); + timeout_member(&pool, community, &target, &actor, future, Some("active")) + .await + .unwrap(); + let state = restriction_state(&pool, community, &target).await.unwrap(); + assert!(!state.banned); + assert_eq!( + state.muted_until.map(|v| v.timestamp()), + Some(future.timestamp()) + ); + assert_eq!(list_restricted(&pool, community).await.unwrap().len(), 1); + assert!(list_restricted(&pool, foreign).await.unwrap().is_empty()); + assert!(untimeout_member(&pool, community, &target, &actor) + .await + .unwrap()); + assert!(!untimeout_member(&pool, community, &target, &actor) + .await + .unwrap()); + ban_member(&pool, community, &target, &actor, None, None) + .await + .unwrap(); + assert!( + restriction_state(&pool, community, &target) + .await + .unwrap() + .banned + ); + assert!(unban_member(&pool, community, &target, &actor) + .await + .unwrap()); + assert!(!unban_member(&pool, community, &target, &actor) + .await + .unwrap()); + assert_eq!( + list_moderation_actions(&pool, community, 10).await.unwrap()[0].id, + action_id + ); + assert!(list_moderation_actions(&pool, foreign, 10) + .await + .unwrap() + .is_empty()); + } + #[tokio::test] async fn thread_metadata_counters_are_idempotent_scoped_and_clamped() { let pool = connect(":memory:").await.unwrap();