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::{
DEFAULT_PERM_TIMEOUT_SECS, PermissionTimeoutMode, is_valid_perm_timeout_secs,
perm_timeout_secs_from_legacy_min, perm_timeout_secs_from_setting,
};
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_SECS: &str = "acp_perm_timeout_secs";
const KEY_ACP_PERM_TIMEOUT_MIN_LEGACY: &str = "acp_perm_timeout_min";
const MIN_ACP_IDLE_RECYCLE_MIN: u64 = 1;
const MAX_ACP_IDLE_RECYCLE_MIN: u64 = 60;
const DEFAULT_ACP_IDLE_RECYCLE_MIN: u64 = 5;
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,
seconds: 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 secs_raw: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_SECS
))
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let legacy_min_raw: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MIN_LEGACY
))
.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 seconds = perm_timeout_secs_from_setting(secs_raw.as_deref())
.or_else(|| perm_timeout_secs_from_legacy_min(legacy_min_raw.as_deref()))
.unwrap_or(DEFAULT_PERM_TIMEOUT_SECS);
Ok(Json(json!({ "mode": mode.as_str(), "seconds": seconds })))
}
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 !is_valid_perm_timeout_secs(req.seconds) {
return Err(StatusCode::BAD_REQUEST);
}
for (key, value) in [
(KEY_ACP_PERM_TIMEOUT_MODE, mode.as_str().to_string()),
(KEY_ACP_PERM_TIMEOUT_SECS, req.seconds.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)?;
}
sqlx::query(&format!("DELETE FROM settings WHERE key = '{}'", KEY_ACP_PERM_TIMEOUT_MIN_LEGACY))
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.acp_perm_timeout.store(mode, req.seconds);
Ok(Json(json!({ "mode": mode.as_str(), "seconds": req.seconds })))
}
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 secs = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_SECS
))
.fetch_optional(db)
.await
.expect("query secs");
(mode, secs)
}
async fn seed_legacy_perm_timeout_min(db: &sqlx::SqlitePool, value: &str) {
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
KEY_ACP_PERM_TIMEOUT_MIN_LEGACY
))
.bind(value)
.execute(db)
.await
.expect("seed legacy 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_30min() {
let state = test_state().await;
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "seconds": 1800 }));
}
#[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_SECS
))
.execute(&state.db)
.await
.expect("seed");
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "seconds": 1800 }));
}
#[tokio::test]
async fn perm_timeout_get_falls_back_to_legacy_minutes_key() {
let state = test_state().await;
seed_legacy_perm_timeout_min(&state.db, "45").await;
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "seconds": 2700 }));
}
#[tokio::test]
async fn perm_timeout_get_prefers_secs_key_over_legacy_minutes() {
let state = test_state().await;
seed_legacy_perm_timeout_min(&state.db, "45").await;
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', '30')",
KEY_ACP_PERM_TIMEOUT_SECS
))
.execute(&state.db)
.await
.expect("seed secs");
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "abort", "seconds": 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(), seconds: 600 }),
)
.await
.expect("put ok");
assert_eq!(res.0, json!({ "mode": "auto", "seconds": 600 }));
assert_eq!(
perm_timeout_db_values(&state.db).await,
(Some("auto".to_string()), Some("600".to_string()))
);
assert_eq!(state.acp_perm_timeout.snapshot(), (PermissionTimeoutMode::Auto, 600));
}
#[tokio::test]
async fn perm_timeout_put_accepts_never_notch_and_clears_legacy_minutes() {
let state = test_state().await;
seed_legacy_perm_timeout_min(&state.db, "45").await;
let res = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: "auto".into(), seconds: 0 }),
)
.await
.expect("put ok");
assert_eq!(res.0, json!({ "mode": "auto", "seconds": 0 }));
assert_eq!(
perm_timeout_db_values(&state.db).await,
(Some("auto".to_string()), Some("0".to_string()))
);
assert_eq!(state.acp_perm_timeout.snapshot(), (PermissionTimeoutMode::Auto, 0));
let legacy_left: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_PERM_TIMEOUT_MIN_LEGACY
))
.fetch_optional(&state.db)
.await
.expect("query legacy");
assert_eq!(legacy_left, None, "legacy minutes key should be cleaned up");
}
#[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(), seconds: 900 }),
)
.await
.expect("seed put ok");
for (mode, seconds) in [("teleport", 900), ("AUTO", 900), ("auto", 45), ("auto", 3630)] {
let err = set_permission_timeout(
State(state.clone()),
Json(SetPermissionTimeoutRequest { mode: mode.into(), seconds }),
)
.await
.expect_err("should reject");
assert_eq!(err, StatusCode::BAD_REQUEST, "mode={mode} seconds={seconds}");
}
assert_eq!(
perm_timeout_db_values(&state.db).await,
(Some("wait".to_string()), Some("900".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(), seconds: 2700 }),
)
.await
.expect("put ok");
let res = get_permission_timeout(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "mode": "wait", "seconds": 2700 }));
}
#[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);
}
}