use axum::extract::Query;
use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
use serde::Deserialize;
use serde_json::json;
use std::sync::atomic::Ordering;
use crate::AppState;
use crate::acp::reaper::{PermissionTimeoutMode, REQUIRES_ACTION_RECYCLE_SECS};
const KEY_ACP_IDLE_RECYCLE_MIN: &str = "acp_idle_recycle_min";
const KEY_ACP_PERM_TIMEOUT_MODE: &str = "acp_perm_timeout_mode";
const KEY_ACP_PERM_TIMEOUT_MIN: &str = "acp_perm_timeout_min";
const MIN_ACP_IDLE_RECYCLE_MIN: u64 = 1;
const MAX_ACP_IDLE_RECYCLE_MIN: u64 = 60;
const MIN_ACP_PERM_TIMEOUT_MIN: u64 = 1;
const MAX_ACP_PERM_TIMEOUT_MIN: u64 = 60;
const DEFAULT_ACP_IDLE_RECYCLE_MIN: u64 = 5;
const DEFAULT_ACP_PERM_TIMEOUT_MIN: u64 = REQUIRES_ACTION_RECYCLE_SECS / 60;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/settings/acp-idle-recycle", get(get_acp_idle_recycle).put(set_acp_idle_recycle))
.route(
"/settings/permission-timeout",
get(get_permission_timeout).put(set_permission_timeout),
)
.route("/settings/audit-log", get(get_audit_log))
}
#[derive(Deserialize)]
struct SetAcpIdleRecycleRequest {
minutes: u64,
}
#[derive(Deserialize)]
struct SetPermissionTimeoutRequest {
mode: String,
minutes: u64,
}
async fn get_acp_idle_recycle(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let value: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_IDLE_RECYCLE_MIN
))
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let minutes = value
.as_deref()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_ACP_IDLE_RECYCLE_MIN);
Ok(Json(json!({ "minutes": minutes })))
}
async fn set_acp_idle_recycle(
State(state): State<AppState>,
Json(req): Json<SetAcpIdleRecycleRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
if !(MIN_ACP_IDLE_RECYCLE_MIN..=MAX_ACP_IDLE_RECYCLE_MIN).contains(&req.minutes) {
return Err(StatusCode::BAD_REQUEST);
}
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
KEY_ACP_IDLE_RECYCLE_MIN
))
.bind(req.minutes.to_string())
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.acp_idle_recycle_secs.store(req.minutes * 60, Ordering::Relaxed);
Ok(Json(json!({ "minutes": req.minutes })))
}
async fn get_permission_timeout(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let mode_raw: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MODE
))
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let min_raw: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MIN
))
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mode =
mode_raw.as_deref().and_then(PermissionTimeoutMode::from_str_opt).unwrap_or_default();
let minutes = min_raw
.as_deref()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_ACP_PERM_TIMEOUT_MIN);
Ok(Json(json!({ "mode": mode.as_str(), "minutes": minutes })))
}
async fn set_permission_timeout(
State(state): State<AppState>,
Json(req): Json<SetPermissionTimeoutRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let Some(mode) = PermissionTimeoutMode::from_str_opt(&req.mode) else {
return Err(StatusCode::BAD_REQUEST);
};
if !(MIN_ACP_PERM_TIMEOUT_MIN..=MAX_ACP_PERM_TIMEOUT_MIN).contains(&req.minutes) {
return Err(StatusCode::BAD_REQUEST);
}
for (key, value) in [
(KEY_ACP_PERM_TIMEOUT_MODE, mode.as_str().to_string()),
(KEY_ACP_PERM_TIMEOUT_MIN, req.minutes.to_string()),
] {
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
key
))
.bind(value)
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
state.acp_perm_timeout.store(mode, req.minutes * 60);
Ok(Json(json!({ "mode": mode.as_str(), "minutes": req.minutes })))
}
async fn get_audit_log(
State(state): State<AppState>,
Query(q): Query<crate::api::audit::AuditLogQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let limit = crate::api::audit::effective_read_limit(q.limit);
let entries = crate::api::audit::list_recent(&state.db, limit)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ "entries": entries })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::reaper::PermissionTimeoutMode;
async fn test_state() -> AppState {
crate::test_utils::test_state().await
}
async fn db_value(db: &sqlx::SqlitePool) -> Option<String> {
sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_IDLE_RECYCLE_MIN
))
.fetch_optional(db)
.await
.expect("query settings")
}
async fn perm_timeout_db_values(db: &sqlx::SqlitePool) -> (Option<String>, Option<String>) {
let mode = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MODE
))
.fetch_optional(db)
.await
.expect("query mode");
let min = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MIN
))
.fetch_optional(db)
.await
.expect("query min");
(mode, min)
}
#[tokio::test]
async fn get_without_record_returns_default_5() {
let state = test_state().await;
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 5 }));
}
#[tokio::test]
async fn get_with_unparseable_record_returns_default_5() {
let state = test_state().await;
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', 'abc')",
KEY_ACP_IDLE_RECYCLE_MIN
))
.execute(&state.db)
.await
.expect("seed");
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 5 }));
}
#[tokio::test]
async fn put_valid_value_persists_and_updates_memory() {
let state = test_state().await;
let res = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
assert_eq!(res.0, json!({ "minutes": 10 }));
assert_eq!(db_value(&state.db).await.as_deref(), Some("10"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 600);
}
#[tokio::test]
async fn put_out_of_range_rejects_and_keeps_db_unchanged() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
for bad in [0, 61] {
let err = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: bad }),
)
.await
.expect_err("should reject");
assert_eq!(err, StatusCode::BAD_REQUEST);
}
assert_eq!(db_value(&state.db).await.as_deref(), Some("10"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 600);
}
#[tokio::test]
async fn put_then_get_returns_updated_value() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 20 }),
)
.await
.expect("put ok");
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 20 }));
}
#[tokio::test]
async fn perm_timeout_get_without_record_returns_default_abort_30() {
let state = test_state().await;
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "minutes": 30 }));
}
#[tokio::test]
async fn perm_timeout_get_with_unparseable_record_returns_default() {
let state = test_state().await;
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', 'teleport'), ('{}', 'abc')",
KEY_ACP_PERM_TIMEOUT_MODE, KEY_ACP_PERM_TIMEOUT_MIN
))
.execute(&state.db)
.await
.expect("seed");
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "minutes": 30 }));
}
#[tokio::test]
async fn perm_timeout_put_valid_persists_and_updates_memory() {
let state = test_state().await;
let res = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: "auto".into(), minutes: 10 }),
)
.await
.expect("put ok");
assert_eq!(res.0, json!({ "mode": "auto", "minutes": 10 }));
assert_eq!(
perm_timeout_db_values(&state.db).await,
(Some("auto".to_string()), Some("10".to_string()))
);
assert_eq!(state.acp_perm_timeout.snapshot(), (PermissionTimeoutMode::Auto, 600));
}
#[tokio::test]
async fn perm_timeout_put_out_of_range_rejects_and_keeps_db_unchanged() {
let state = test_state().await;
let _ = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: "wait".into(), minutes: 15 }),
)
.await
.expect("seed put ok");
for (mode, minutes) in [("teleport", 15), ("AUTO", 15), ("auto", 0), ("auto", 61)] {
let err = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: mode.into(), minutes }),
)
.await
.expect_err("should reject");
assert_eq!(err, StatusCode::BAD_REQUEST, "mode={mode} minutes={minutes}");
}
assert_eq!(
perm_timeout_db_values(&state.db).await,
(Some("wait".to_string()), Some("15".to_string()))
);
assert_eq!(state.acp_perm_timeout.snapshot(), (PermissionTimeoutMode::Wait, 900));
}
#[tokio::test]
async fn perm_timeout_put_then_get_returns_updated_value() {
let state = test_state().await;
let _ = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: "wait".into(), minutes: 45 }),
)
.await
.expect("put ok");
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "wait", "minutes": 45 }));
}
#[tokio::test]
async fn put_overwrites_existing_value() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 30 }),
)
.await
.expect("put ok");
assert_eq!(db_value(&state.db).await.as_deref(), Some("30"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 1800);
}
}