A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365//! Lua bindings for labeler + moderation operations.//!//! Scripts attached to service hooks use these to emit labels and resolve//! reports as side effects. They thinly wrap the existing//! `labeler::emitter::{emit, negate}` and `moderation::store::{resolve, get,//! list}` paths so all the same validation, signing, and broadcasting still//! happens.//!//! IMPORTANT: these calls are unauthenticated from Lua's perspective — the//! hook caller has already passed admin/XRPC auth at the originating//! request, and the script runs server-side. Don't expose these APIs to//! query/procedure scripts that take untrusted input directly.//!//! Re-entry note: `labeler.emit` from a `report.created` hook will trigger//! the `label.emitted` hook chain. The instruction-count limit and the//! per-hook retry budget bound runaway loops, but scripts should still//! avoid recursive emission by design.
use mlua::{Lua, LuaSerdeExt, Result as LuaResult};use std::sync::Arc;
use crate::AppState;use crate::labeler::emitter::{self, EmitRequest, NegateRequest};use crate::lua::service_hooks::{self, LabelEmittedEvent, ReportResolvedEvent};use crate::moderation::store::{self, ListFilter, Sort};use crate::moderation::types::ReportStatus;
/// Register the `labeler` table with `emit` and `negate` async functions.pub fn register_labeler_api(lua: &Lua, state: Arc<AppState>) -> LuaResult<()> { let labeler = lua.create_table()?;
let state_emit = state.clone(); let emit_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_emit.clone(); async move { let uri: String = opts.get("uri").map_err(|e| { mlua::Error::runtime(format!("labeler.emit: missing 'uri': {e}")) })?; let val: String = opts.get("val").map_err(|e| { mlua::Error::runtime(format!("labeler.emit: missing 'val': {e}")) })?; let cid: Option<String> = opts.get("cid").ok(); let exp: Option<String> = opts.get("exp").ok();
// Fire the label.emitted hook chain — same as the admin path. // A nested labeler.emit from a hook will recursively re-enter // here, but the per-script instruction limit + retry budget // bound runaway. Scripts should still avoid recursive emission. let event = service_hooks::dispatch_label_emitted( &state, LabelEmittedEvent { uri, cid, val, neg: false, exp, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.emit: {e}")))?;
let identity = state.labeler_identity.read().await.clone(); let label = emitter::emit( &state.db, state.db_backend, identity.as_ref(), &state.labeler_definitions, &state.emitted_label_tx, EmitRequest { uri: event.uri, val: event.val, cid: event.cid, exp: event.exp, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.emit: {e}")))?;
// Hand back a table mirroring the persisted label. `sig` is hex. let result = lua.create_table()?; result.set("seq", label.seq)?; result.set("src", label.src)?; result.set("uri", label.uri)?; if let Some(c) = label.cid { result.set("cid", c)?; } result.set("val", label.val)?; result.set("neg", label.neg)?; result.set("cts", label.cts)?; if let Some(e) = label.exp { result.set("exp", e)?; } result.set("sig", hex::encode(&label.sig))?; Ok(mlua::Value::Table(result)) } })?; labeler.set("emit", emit_fn)?;
let state_negate = state.clone(); let negate_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_negate.clone(); async move { let uri: String = opts.get("uri").map_err(|e| { mlua::Error::runtime(format!("labeler.negate: missing 'uri': {e}")) })?; let val: String = opts.get("val").map_err(|e| { mlua::Error::runtime(format!("labeler.negate: missing 'val': {e}")) })?;
// Fire the label.emitted hook chain with neg=true. Hooks can // tell apply from negate via `event.neg`. let event = service_hooks::dispatch_label_emitted( &state, LabelEmittedEvent { uri, cid: None, val, neg: true, exp: None, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.negate: {e}")))?;
let identity = state.labeler_identity.read().await.clone(); let label = emitter::negate( &state.db, state.db_backend, identity.as_ref(), &state.labeler_definitions, &state.emitted_label_tx, NegateRequest { uri: event.uri, val: event.val, }, ) .await .map_err(|e| mlua::Error::runtime(format!("labeler.negate: {e}")))?;
let result = lua.create_table()?; result.set("seq", label.seq)?; result.set("src", label.src)?; result.set("uri", label.uri)?; result.set("val", label.val)?; result.set("neg", label.neg)?; result.set("cts", label.cts)?; result.set("sig", hex::encode(&label.sig))?; Ok(mlua::Value::Table(result)) } })?; labeler.set("negate", negate_fn)?;
lua.globals().set("labeler", labeler)?; Ok(())}
/// Register the `moderation` table with report-management functions.pub fn register_moderation_api(lua: &Lua, state: Arc<AppState>) -> LuaResult<()> { let moderation = lua.create_table()?;
// moderation.action(id, note?, by?) — flip status to actioned. Fires // the report.resolved hook chain so downstream hooks see the // transition uniformly with admin-driven resolutions. { let state = state.clone(); let func = lua.create_async_function( move |lua, (id, note, by): (i64, Option<String>, Option<String>)| { let state = state.clone(); async move { let by = by.unwrap_or_else(|| "service-hook".to_string()); let report = resolve_with_hooks(&state, id, ReportStatus::Actioned, by, note).await?; let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize report: {e}")))?; lua.to_value(&value).map_err(|e| { mlua::Error::runtime(format!("convert report to lua: {e}")) }) } }, )?; moderation.set("action", func)?; }
// moderation.dismiss(id, note?, by?) — flip status to dismissed. { let state = state.clone(); let func = lua.create_async_function( move |lua, (id, note, by): (i64, Option<String>, Option<String>)| { let state = state.clone(); async move { let by = by.unwrap_or_else(|| "service-hook".to_string()); let report = resolve_with_hooks(&state, id, ReportStatus::Dismissed, by, note).await?; let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize report: {e}")))?; lua.to_value(&value).map_err(|e| { mlua::Error::runtime(format!("convert report to lua: {e}")) }) } }, )?; moderation.set("dismiss", func)?; }
// moderation.get_report(id) — fetch a report by id (nil on not-found) { let state = state.clone(); let func = lua.create_async_function(move |lua, id: i64| { let state = state.clone(); async move { match store::get(&state.db, state.db_backend, id).await { Ok(report) => { let value = serde_json::to_value(&report) .map_err(|e| mlua::Error::runtime(format!("serialize: {e}")))?; lua.to_value(&value) .map_err(|e| mlua::Error::runtime(format!("convert: {e}"))) } Err(store::StoreError::NotFound) => Ok(mlua::Value::Nil), Err(e) => Err(mlua::Error::runtime(format!("moderation.get_report: {e}"))), } } })?; moderation.set("get_report", func)?; }
// moderation.list_reports({ status?, subject_did?, subject_uri?, // reporter_did?, limit?, sort? }) // → { reports = [...], cursor = <next> | nil } { let state = state.clone(); let func = lua.create_async_function(move |lua, opts: Option<mlua::Table>| { let state = state.clone(); async move { let opts = opts.unwrap_or(lua.create_table()?); let mut filter = ListFilter { limit: opts.get::<i64>("limit").unwrap_or(50), ..Default::default() }; if let Ok(s) = opts.get::<String>("status") { filter.status = ReportStatus::from_str(&s); if filter.status.is_none() { return Err(mlua::Error::runtime(format!( "moderation.list_reports: invalid status '{s}' \ (open|actioned|dismissed)" ))); } } if let Ok(s) = opts.get::<String>("subject_did") { filter.subject_did = Some(s); } if let Ok(s) = opts.get::<String>("subject_uri") { filter.subject_uri = Some(s); } if let Ok(s) = opts.get::<String>("reporter_did") { filter.reporter_did = Some(s); } if let Ok(c) = opts.get::<i64>("cursor") { filter.cursor = Some(c); } if let Ok(s) = opts.get::<String>("sort") { filter.sort = Sort::from_str(&s).ok_or_else(|| { mlua::Error::runtime(format!( "moderation.list_reports: invalid sort '{s}' \ (newest|oldest|subject)" )) })?; }
let (reports, next_cursor) = store::list(&state.db, state.db_backend, &filter) .await .map_err(|e| { mlua::Error::runtime(format!("moderation.list_reports: {e}")) })?;
let result = lua.create_table()?; let reports_value = serde_json::to_value(&reports) .map_err(|e| mlua::Error::runtime(format!("serialize reports: {e}")))?; let reports_lua = lua.to_value(&reports_value).map_err(|e| { mlua::Error::runtime(format!("convert reports to lua: {e}")) })?; // Force array shape when empty so json.encode round-trips. if let mlua::Value::Table(t) = &reports_lua { t.set_metatable(Some(lua.array_metatable()))?; } result.set("reports", reports_lua)?; if let Some(c) = next_cursor { result.set("cursor", c)?; } Ok(mlua::Value::Table(result)) } })?; moderation.set("list_reports", func)?; }
// moderation.list_subjects({ only_open?, limit? }) — aggregated view { let state = state.clone(); let func = lua.create_async_function(move |lua, opts: Option<mlua::Table>| { let state = state.clone(); async move { let opts = opts.unwrap_or(lua.create_table()?); let only_open: bool = opts.get("only_open").unwrap_or(false); let limit: i64 = opts.get("limit").unwrap_or(50); let summaries = store::list_subjects(&state.db, state.db_backend, only_open, limit) .await .map_err(|e| { mlua::Error::runtime(format!("moderation.list_subjects: {e}")) })?; let value = serde_json::to_value(&summaries) .map_err(|e| mlua::Error::runtime(format!("serialize subjects: {e}")))?; let v = lua .to_value(&value) .map_err(|e| mlua::Error::runtime(format!("convert subjects: {e}")))?; if let mlua::Value::Table(t) = &v { t.set_metatable(Some(lua.array_metatable()))?; } Ok(v) } })?; moderation.set("list_subjects", func)?; }
lua.globals().set("moderation", moderation)?; Ok(())}
/// Shared `moderation.action` / `moderation.dismiss` body: load the/// current report, fire the report.resolved hook chain, then write.async fn resolve_with_hooks( state: &Arc<AppState>, id: i64, new_status: ReportStatus, resolved_by: String, note: Option<String>,) -> mlua::Result<crate::moderation::types::Report> { let current = store::get(&state.db, state.db_backend, id) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", new_status.as_str())))?;
let event = service_hooks::dispatch_report_resolved( state, ReportResolvedEvent { report: current, new_status, resolved_by, note, }, ) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", new_status.as_str())))?;
store::resolve( &state.db, state.db_backend, id, event.new_status, &event.resolved_by, event.note.as_deref(), ) .await .map_err(|e| mlua::Error::runtime(format!("moderation.{}: {e}", event.new_status.as_str())))}