use axum::Extension;
use axum::Json;
use axum::body::Bytes;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{Pool, Sqlite};
use crate::error::Error;
use crate::moderation::types::ActionType;
use crate::writer::{
RecordActionRequest, RecordedAction, RevokeActionRequest, RevokedAction, WriterHandle,
};
use crate::xrpc_gateway::XrpcAuthClaims;
use super::XrpcGatewayState;
pub const XRPC_GATEWAY_DEFAULT_REASON_CODE: &str = "xrpc-gateway-default";
#[derive(Debug, Deserialize)]
pub struct EmitEventRequest {
pub subject: Value,
pub event: Value,
#[serde(rename = "createdBy")]
pub created_by: String,
#[serde(rename = "subjectBlobCids", default)]
pub subject_blob_cids: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RepoRef {
#[serde(rename = "$type")]
pub r#type: String,
pub did: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "$type")]
pub enum ModEvent {
#[serde(rename = "tools.ozone.moderation.defs#modEventLabel")]
Label {
#[serde(rename = "createLabelVals")]
create_label_vals: Vec<String>,
#[serde(rename = "negateLabelVals", default)]
negate_label_vals: Vec<String>,
#[serde(default)]
comment: Option<String>,
},
#[serde(rename = "tools.ozone.moderation.defs#modEventTakedown")]
Takedown {
#[serde(default)]
comment: Option<String>,
#[serde(rename = "durationInHours", default)]
duration_in_hours: Option<u32>,
#[serde(rename = "acknowledgeAccountSubjects", default)]
acknowledge_account_subjects: Option<bool>,
},
#[serde(rename = "tools.ozone.moderation.defs#modEventReverseTakedown")]
ReverseTakedown {
#[serde(default)]
comment: Option<String>,
},
#[serde(rename = "tools.ozone.moderation.defs#modEventComment")]
Comment {
comment: String,
#[serde(default)]
sticky: Option<bool>,
},
#[serde(other)]
Unsupported,
}
#[derive(Debug, Serialize)]
pub struct ModEventView {
pub id: i64,
pub event: Value,
pub subject: Value,
#[serde(rename = "subjectBlobCids")]
pub subject_blob_cids: Vec<String>,
#[serde(rename = "createdBy")]
pub created_by: String,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "creatorHandle", skip_serializing_if = "Option::is_none")]
pub creator_handle: Option<String>,
}
pub(crate) async fn handler(
Extension(state): Extension<XrpcGatewayState>,
Extension(claims): Extension<XrpcAuthClaims>,
body: Bytes,
) -> Response {
let req: EmitEventRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => return invalid_request(format!("malformed request body: {e}")),
};
let subject_did = match parse_subject_did(&req.subject) {
Ok(d) => d,
Err(msg) => return invalid_request(msg),
};
if req.created_by != claims.iss {
return invalid_request(format!(
"createdBy {} does not match authenticated issuer {}",
req.created_by, claims.iss
));
}
let event: ModEvent = match serde_json::from_value(req.event.clone()) {
Ok(e) => e,
Err(e) => return invalid_request(format!("malformed event body: {e}")),
};
match event {
ModEvent::Label {
create_label_vals,
negate_label_vals,
comment,
} => {
handle_label(
&state.writer,
&claims.iss,
&subject_did,
create_label_vals,
negate_label_vals,
comment,
req.subject,
req.event,
req.subject_blob_cids,
)
.await
}
ModEvent::Takedown {
comment,
duration_in_hours,
acknowledge_account_subjects: _,
} => {
handle_takedown(
&state.writer,
&claims.iss,
&subject_did,
comment,
duration_in_hours,
req.subject,
req.event,
req.subject_blob_cids,
)
.await
}
ModEvent::ReverseTakedown { comment } => {
handle_reverse_takedown(
&state.writer,
&state.pool,
&claims.iss,
&subject_did,
comment,
req.subject,
req.event,
req.subject_blob_cids,
)
.await
}
ModEvent::Comment { comment, sticky: _ } => {
handle_comment(
&state.writer,
&claims.iss,
&subject_did,
comment,
req.subject,
req.event,
req.subject_blob_cids,
)
.await
}
ModEvent::Unsupported => {
let ty = req
.event
.get("$type")
.and_then(Value::as_str)
.unwrap_or("<unknown>");
invalid_request(format!("event $type {ty} is not supported by cairn-mod"))
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_label(
writer: &WriterHandle,
actor_did: &str,
subject_did: &str,
create_label_vals: Vec<String>,
negate_label_vals: Vec<String>,
comment: Option<String>,
subject_echo: Value,
event_echo: Value,
blobs_echo: Vec<String>,
) -> Response {
if !negate_label_vals.is_empty() {
return invalid_request(
"modEventLabel.negateLabelVals is not supported in v1.7; \
call tools.cairn.admin.negateLabel instead"
.to_string(),
);
}
if create_label_vals.is_empty() {
return invalid_request("modEventLabel.createLabelVals must be non-empty".to_string());
}
let req = RecordActionRequest {
subject: subject_did.to_string(),
actor_did: actor_did.to_string(),
action_type: ActionType::Warning,
reason_codes: create_label_vals,
duration_iso: None,
notes: comment,
report_ids: Vec::new(),
};
match writer.record_action(req).await {
Ok(recorded) => {
recorded_action_view(recorded, actor_did, subject_echo, event_echo, blobs_echo)
}
Err(e) => map_record_action_error(e),
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_takedown(
writer: &WriterHandle,
actor_did: &str,
subject_did: &str,
comment: Option<String>,
duration_in_hours: Option<u32>,
subject_echo: Value,
event_echo: Value,
blobs_echo: Vec<String>,
) -> Response {
let (action_type, duration_iso) = match duration_in_hours {
Some(0) => {
return invalid_request(
"modEventTakedown.durationInHours must be > 0 when set".to_string(),
);
}
Some(h) => (ActionType::TempSuspension, Some(format!("PT{h}H"))),
None => (ActionType::Takedown, None),
};
let req = RecordActionRequest {
subject: subject_did.to_string(),
actor_did: actor_did.to_string(),
action_type,
reason_codes: vec![XRPC_GATEWAY_DEFAULT_REASON_CODE.to_string()],
duration_iso,
notes: comment,
report_ids: Vec::new(),
};
match writer.record_action(req).await {
Ok(recorded) => {
recorded_action_view(recorded, actor_did, subject_echo, event_echo, blobs_echo)
}
Err(e) => map_record_action_error(e),
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_reverse_takedown(
writer: &WriterHandle,
pool: &Pool<Sqlite>,
actor_did: &str,
subject_did: &str,
comment: Option<String>,
subject_echo: Value,
event_echo: Value,
blobs_echo: Vec<String>,
) -> Response {
let action_id = match find_active_takedown_action_id(pool, subject_did).await {
Ok(Some(id)) => id,
Ok(None) => {
return invalid_request(format!(
"no active takedown to reverse for subject {subject_did}"
));
}
Err(e) => {
tracing::error!(
error = %e,
subject = subject_did,
"xrpc_gateway emitEvent: takedown lookup failed"
);
return internal_server_error();
}
};
let req = RevokeActionRequest {
action_id,
revoked_by_did: actor_did.to_string(),
revoked_reason: comment,
};
match writer.revoke_action(req).await {
Ok(revoked) => {
revoked_action_view(revoked, actor_did, subject_echo, event_echo, blobs_echo)
}
Err(e) => map_revoke_action_error(e),
}
}
async fn handle_comment(
writer: &WriterHandle,
actor_did: &str,
subject_did: &str,
comment: String,
subject_echo: Value,
event_echo: Value,
blobs_echo: Vec<String>,
) -> Response {
let req = RecordActionRequest {
subject: subject_did.to_string(),
actor_did: actor_did.to_string(),
action_type: ActionType::Note,
reason_codes: vec![XRPC_GATEWAY_DEFAULT_REASON_CODE.to_string()],
duration_iso: None,
notes: Some(comment),
report_ids: Vec::new(),
};
match writer.record_action(req).await {
Ok(recorded) => {
recorded_action_view(recorded, actor_did, subject_echo, event_echo, blobs_echo)
}
Err(e) => map_record_action_error(e),
}
}
pub(crate) fn parse_subject_did(subject: &Value) -> Result<String, String> {
let ty = subject
.get("$type")
.and_then(Value::as_str)
.ok_or_else(|| "subject is missing $type discriminator".to_string())?;
if ty != "com.atproto.admin.defs#repoRef" {
return Err(format!(
"subject $type {ty} is not supported; only com.atproto.admin.defs#repoRef is accepted in v1.7"
));
}
let did = subject
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| "subject.did is missing or not a string".to_string())?;
if !did.starts_with("did:") {
return Err(format!("subject.did {did:?} is not a DID"));
}
Ok(did.to_string())
}
async fn find_active_takedown_action_id(
pool: &Pool<Sqlite>,
subject_did: &str,
) -> sqlx::Result<Option<i64>> {
let row = sqlx::query!(
r#"SELECT id as "id!: i64"
FROM subject_actions
WHERE subject_did = ?1
AND action_type = 'takedown'
AND revoked_at IS NULL
ORDER BY id DESC
LIMIT 1"#,
subject_did,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.id))
}
fn recorded_action_view(
recorded: RecordedAction,
actor_did: &str,
subject: Value,
event: Value,
blobs: Vec<String>,
) -> Response {
let view = ModEventView {
id: recorded.action_id,
event,
subject,
subject_blob_cids: blobs,
created_by: actor_did.to_string(),
created_at: crate::writer::rfc3339_from_epoch_ms(crate::writer::epoch_ms_now())
.unwrap_or_else(|_| String::from("1970-01-01T00:00:00.000Z")),
creator_handle: None,
};
(StatusCode::OK, Json(view)).into_response()
}
fn revoked_action_view(
revoked: RevokedAction,
actor_did: &str,
subject: Value,
event: Value,
blobs: Vec<String>,
) -> Response {
let view = ModEventView {
id: revoked.action_id,
event,
subject,
subject_blob_cids: blobs,
created_by: actor_did.to_string(),
created_at: revoked.revoked_at,
creator_handle: None,
};
(StatusCode::OK, Json(view)).into_response()
}
fn map_record_action_error(e: Error) -> Response {
match e {
Error::ReasonNotFound(name) => invalid_request(format!(
"reason code {name:?} not declared in [moderation_reasons]; \
see XRPC_GATEWAY_DEFAULT_REASON_CODE in §F23 docs"
)),
Error::DurationRequiredForTempSuspension => {
invalid_request("durationInHours must be set for temp-suspension Takedown".to_string())
}
Error::DurationOnlyForTempSuspension => invalid_request(
"durationInHours is only valid for temp-suspension Takedown".to_string(),
),
Error::SubjectUriMismatch => {
invalid_request("subject URI repo does not match subject DID".to_string())
}
Error::Signing(msg) => invalid_request(msg),
other => {
tracing::error!(
error = %other,
"xrpc_gateway emitEvent: writer.record_action failed"
);
internal_server_error()
}
}
}
fn map_revoke_action_error(e: Error) -> Response {
match e {
Error::ActionNotFound(_) => {
invalid_request("no active takedown to reverse".to_string())
}
Error::ActionAlreadyRevoked(_) => {
invalid_request("takedown is already revoked".to_string())
}
Error::Signing(msg) => invalid_request(msg),
other => {
tracing::error!(
error = %other,
"xrpc_gateway emitEvent: writer.revoke_action failed"
);
internal_server_error()
}
}
}
fn invalid_request(message: String) -> Response {
let body = ErrorEnvelope {
error: "InvalidRequest",
message,
};
(StatusCode::BAD_REQUEST, Json(body)).into_response()
}
fn internal_server_error() -> Response {
let body = ErrorEnvelope {
error: "InternalServerError",
message: "service temporarily unavailable".to_string(),
};
(StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
}
#[derive(Serialize)]
struct ErrorEnvelope {
error: &'static str,
message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_subject_accepts_repo_ref() {
let v = json!({
"$type": "com.atproto.admin.defs#repoRef",
"did": "did:plc:abc"
});
assert_eq!(parse_subject_did(&v).unwrap(), "did:plc:abc");
}
#[test]
fn parse_subject_rejects_strong_ref() {
let v = json!({
"$type": "com.atproto.repo.strongRef",
"uri": "at://did:plc:abc/app.bsky.feed.post/x",
"cid": "bafy...",
});
let err = parse_subject_did(&v).unwrap_err();
assert!(err.contains("strongRef") || err.contains("not supported"));
}
#[test]
fn parse_subject_rejects_missing_type() {
let v = json!({ "did": "did:plc:abc" });
let err = parse_subject_did(&v).unwrap_err();
assert!(err.contains("$type"));
}
#[test]
fn parse_subject_rejects_non_did_value() {
let v = json!({
"$type": "com.atproto.admin.defs#repoRef",
"did": "not-a-did"
});
let err = parse_subject_did(&v).unwrap_err();
assert!(err.contains("not a DID"));
}
#[test]
fn parse_mod_event_label() {
let v = json!({
"$type": "tools.ozone.moderation.defs#modEventLabel",
"createLabelVals": ["spam", "harassment"],
});
let e: ModEvent = serde_json::from_value(v).unwrap();
match e {
ModEvent::Label {
create_label_vals, ..
} => {
assert_eq!(create_label_vals, vec!["spam", "harassment"]);
}
other => panic!("expected Label, got {other:?}"),
}
}
#[test]
fn parse_mod_event_takedown_with_duration() {
let v = json!({
"$type": "tools.ozone.moderation.defs#modEventTakedown",
"comment": "spam farm",
"durationInHours": 168,
});
let e: ModEvent = serde_json::from_value(v).unwrap();
match e {
ModEvent::Takedown {
duration_in_hours,
comment,
..
} => {
assert_eq!(duration_in_hours, Some(168));
assert_eq!(comment.as_deref(), Some("spam farm"));
}
other => panic!("expected Takedown, got {other:?}"),
}
}
#[test]
fn parse_mod_event_reverse_takedown() {
let v = json!({
"$type": "tools.ozone.moderation.defs#modEventReverseTakedown",
"comment": "appeal granted",
});
let e: ModEvent = serde_json::from_value(v).unwrap();
assert!(matches!(e, ModEvent::ReverseTakedown { .. }));
}
#[test]
fn parse_mod_event_comment() {
let v = json!({
"$type": "tools.ozone.moderation.defs#modEventComment",
"comment": "needs second review",
});
let e: ModEvent = serde_json::from_value(v).unwrap();
match e {
ModEvent::Comment { comment, .. } => {
assert_eq!(comment, "needs second review");
}
other => panic!("expected Comment, got {other:?}"),
}
}
#[test]
fn parse_mod_event_unsupported_falls_through_to_unsupported_variant() {
let v = json!({
"$type": "tools.ozone.moderation.defs#modEventMute",
"durationInHours": 24,
});
let e: ModEvent = serde_json::from_value(v).unwrap();
assert!(matches!(e, ModEvent::Unsupported));
}
#[test]
fn parse_emit_event_request_round_trip() {
let body = json!({
"subject": {
"$type": "com.atproto.admin.defs#repoRef",
"did": "did:plc:target"
},
"event": {
"$type": "tools.ozone.moderation.defs#modEventComment",
"comment": "x",
},
"createdBy": "did:plc:moderator",
"subjectBlobCids": [],
});
let req: EmitEventRequest = serde_json::from_value(body.clone()).unwrap();
assert_eq!(req.created_by, "did:plc:moderator");
assert_eq!(req.event, body["event"]);
assert_eq!(req.subject, body["subject"]);
}
#[test]
fn default_reason_code_is_hyphenated() {
assert_eq!(XRPC_GATEWAY_DEFAULT_REASON_CODE, "xrpc-gateway-default");
}
}