use axum::Extension;
use axum::Json;
use axum::extract::RawQuery;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use sqlx::{QueryBuilder, Sqlite};
use crate::writer::parse_rfc3339_ms;
use super::common::{AdminError, AdminState, verify_and_authorize};
use super::subject_action_view::{SubjectActionEntry, SubjectActionRow, project};
use crate::server::strike_state::subject_has_history;
use crate::server::xrpc::{decode_cursor, encode_cursor};
const LXM: &str = "tools.cairn.admin.getSubjectHistory";
const DEFAULT_LIMIT: i64 = 50;
const MIN_LIMIT: i64 = 1;
const MAX_LIMIT: i64 = 250;
#[derive(Debug, Default)]
struct Params {
subject: String,
subject_uri: Option<String>,
since_ms: Option<i64>,
include_revoked: bool,
limit: i64,
cursor: Option<String>,
}
#[derive(Debug, Serialize)]
struct Output {
actions: Vec<SubjectActionEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
pub(super) async fn handler(
Extension(state): Extension<AdminState>,
headers: HeaderMap,
RawQuery(query): RawQuery,
) -> Response {
if let Err(e) = verify_and_authorize(&state, &headers, LXM).await {
return e.into_response();
}
let params = match parse_params(query.as_deref().unwrap_or("")) {
Ok(p) => p,
Err(e) => return e.into_response(),
};
let cursor_id = match ¶ms.cursor {
None => i64::MAX,
Some(c) => match decode_cursor(c) {
Ok(n) => n,
Err(_) => return AdminError::InvalidRequest("malformed cursor").into_response(),
},
};
match subject_has_history(&state.pool, ¶ms.subject).await {
Ok(true) => {}
Ok(false) => return AdminError::SubjectNotFound.into_response(),
Err(_) => return AdminError::Internal.into_response(),
}
let mut qb = QueryBuilder::<Sqlite>::new(
"SELECT id, subject_did, subject_uri, actor_did, action_type, reason_codes,
duration, effective_at, expires_at, notes, report_ids,
strike_value_base, strike_value_applied, was_dampened,
strikes_at_time_of_action, revoked_at, revoked_by_did, revoked_reason,
audit_log_id, created_at
FROM subject_actions
WHERE subject_did = ",
);
qb.push_bind(params.subject.clone());
qb.push(" AND id < ");
qb.push_bind(cursor_id);
if let Some(uri) = ¶ms.subject_uri {
qb.push(" AND subject_uri = ");
qb.push_bind(uri.clone());
}
if let Some(s) = params.since_ms {
qb.push(" AND effective_at >= ");
qb.push_bind(s);
}
if !params.include_revoked {
qb.push(" AND revoked_at IS NULL");
}
qb.push(" ORDER BY id DESC LIMIT ");
qb.push_bind(params.limit + 1);
let rows: Vec<SubjectActionRow> = match qb
.build_query_as::<SubjectActionRow>()
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(_) => return AdminError::Internal.into_response(),
};
let (returned, next_cursor) = if rows.len() as i64 > params.limit {
let mut trimmed = rows;
trimmed.truncate(params.limit as usize);
let last_id = trimmed.last().expect("trimmed non-empty").id;
(trimmed, Some(encode_cursor(last_id)))
} else {
(rows, None)
};
let mut actions = Vec::with_capacity(returned.len());
for row in returned {
match project(row) {
Ok(e) => actions.push(e),
Err(_) => return AdminError::Internal.into_response(),
}
}
Json(Output {
actions,
cursor: next_cursor,
})
.into_response()
}
fn parse_params(raw: &str) -> Result<Params, AdminError> {
let mut p = Params {
limit: DEFAULT_LIMIT,
include_revoked: true, ..Default::default()
};
for (k, v) in form_urlencoded::parse(raw.as_bytes()) {
match k.as_ref() {
"subject" if !v.is_empty() => p.subject = v.into_owned(),
"subjectUri" if !v.is_empty() => p.subject_uri = Some(v.into_owned()),
"since" if !v.is_empty() => {
let ms = parse_rfc3339_ms(v.as_ref())
.map_err(|_| AdminError::InvalidRequest("malformed since"))?;
p.since_ms = Some(ms);
}
"includeRevoked" if !v.is_empty() => {
p.include_revoked = match v.as_ref() {
"true" | "1" => true,
"false" | "0" => false,
_ => {
return Err(AdminError::InvalidRequest(
"includeRevoked must be true or false",
));
}
};
}
"limit" => {
let n = v
.parse::<i64>()
.map_err(|_| AdminError::InvalidRequest("limit must be integer"))?;
if !(MIN_LIMIT..=MAX_LIMIT).contains(&n) {
return Err(AdminError::InvalidRequest("limit out of range"));
}
p.limit = n;
}
"cursor" if !v.is_empty() => p.cursor = Some(v.into_owned()),
_ => {}
}
}
if p.subject.is_empty() {
return Err(AdminError::InvalidRequest("subject is required"));
}
if !p.subject.starts_with("did:") {
return Err(AdminError::InvalidRequest("subject must be a DID"));
}
Ok(p)
}