zeroclaw/src/gateway/api.rs
2026-02-21 19:38:19 +08:00

536 lines
16 KiB
Rust

//! REST API handlers for the web dashboard.
//!
//! All `/api/*` routes require bearer token authentication (PairingGuard).
use super::AppState;
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use serde::Deserialize;
// ── Bearer token auth extractor ─────────────────────────────────
/// Extract and validate bearer token from Authorization header.
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
}
/// Verify bearer token against PairingGuard. Returns error response if unauthorized.
fn require_auth(
state: &AppState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if !state.pairing.require_pairing() {
return Ok(());
}
let token = extract_bearer_token(headers).unwrap_or("");
if state.pairing.is_authenticated(token) {
Ok(())
} else {
Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
})),
))
}
}
// ── Query parameters ─────────────────────────────────────────────
#[derive(Deserialize)]
pub struct MemoryQuery {
pub query: Option<String>,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct MemoryStoreBody {
pub key: String,
pub content: String,
pub category: Option<String>,
}
#[derive(Deserialize)]
pub struct CronAddBody {
pub name: Option<String>,
pub schedule: String,
pub command: String,
}
// ── Handlers ────────────────────────────────────────────────────
/// GET /api/status — system status overview
pub async fn handle_api_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
let health = crate::health::snapshot();
let mut channels = serde_json::Map::new();
for (channel, present) in config.channels_config.channels() {
channels.insert(channel.name().to_string(), serde_json::Value::Bool(present));
}
let body = serde_json::json!({
"provider": config.default_provider,
"model": state.model,
"temperature": state.temperature,
"uptime_seconds": health.uptime_seconds,
"gateway_port": config.gateway.port,
"locale": "en",
"memory_backend": state.mem.name(),
"paired": state.pairing.is_paired(),
"channels": channels,
"health": health,
});
Json(body).into_response()
}
/// GET /api/config — current config (api_key masked)
pub async fn handle_api_config_get(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
// Serialize to TOML, then mask sensitive fields
let toml_str = match toml::to_string_pretty(&config) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to serialize config: {e}")})),
)
.into_response();
}
};
// Mask api_key in the TOML output
let masked = mask_sensitive_fields(&toml_str);
Json(serde_json::json!({
"format": "toml",
"content": masked,
}))
.into_response()
}
/// PUT /api/config — update config from TOML body
pub async fn handle_api_config_put(
State(state): State<AppState>,
headers: HeaderMap,
body: String,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
// Parse the incoming TOML
let new_config: crate::config::Config = match toml::from_str(&body) {
Ok(c) => c,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("Invalid TOML: {e}")})),
)
.into_response();
}
};
// Save to disk
if let Err(e) = new_config.save().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
)
.into_response();
}
// Update in-memory config
*state.config.lock() = new_config;
Json(serde_json::json!({"status": "ok"})).into_response()
}
/// GET /api/tools — list registered tool specs
pub async fn handle_api_tools(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let tools: Vec<serde_json::Value> = state
.tools_registry
.iter()
.map(|spec| {
serde_json::json!({
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
})
})
.collect();
Json(serde_json::json!({"tools": tools})).into_response()
}
/// GET /api/cron — list cron jobs
pub async fn handle_api_cron_list(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
match crate::cron::list_jobs(&config) {
Ok(jobs) => {
let jobs_json: Vec<serde_json::Value> = jobs
.iter()
.map(|job| {
serde_json::json!({
"id": job.id,
"name": job.name,
"command": job.command,
"next_run": job.next_run.to_rfc3339(),
"last_run": job.last_run.map(|t| t.to_rfc3339()),
"last_status": job.last_status,
"enabled": job.enabled,
})
})
.collect();
Json(serde_json::json!({"jobs": jobs_json})).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to list cron jobs: {e}")})),
)
.into_response(),
}
}
/// POST /api/cron — add a new cron job
pub async fn handle_api_cron_add(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CronAddBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
let schedule = crate::cron::Schedule::Cron {
expr: body.schedule,
tz: None,
};
match crate::cron::add_shell_job(&config, body.name, schedule, &body.command) {
Ok(job) => Json(serde_json::json!({
"status": "ok",
"job": {
"id": job.id,
"name": job.name,
"command": job.command,
"enabled": job.enabled,
}
}))
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to add cron job: {e}")})),
)
.into_response(),
}
}
/// DELETE /api/cron/:id — remove a cron job
pub async fn handle_api_cron_delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
match crate::cron::remove_job(&config, &id) {
Ok(()) => Json(serde_json::json!({"status": "ok"})).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to remove cron job: {e}")})),
)
.into_response(),
}
}
/// GET /api/integrations — list all integrations with status
pub async fn handle_api_integrations(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
let entries = crate::integrations::registry::all_integrations();
let integrations: Vec<serde_json::Value> = entries
.iter()
.map(|entry| {
let status = (entry.status_fn)(&config);
serde_json::json!({
"name": entry.name,
"description": entry.description,
"category": entry.category,
"status": status,
})
})
.collect();
Json(serde_json::json!({"integrations": integrations})).into_response()
}
/// POST /api/doctor — run diagnostics
pub async fn handle_api_doctor(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
let results = crate::doctor::diagnose(&config);
let ok_count = results
.iter()
.filter(|r| r.severity == crate::doctor::Severity::Ok)
.count();
let warn_count = results
.iter()
.filter(|r| r.severity == crate::doctor::Severity::Warn)
.count();
let error_count = results
.iter()
.filter(|r| r.severity == crate::doctor::Severity::Error)
.count();
Json(serde_json::json!({
"results": results,
"summary": {
"ok": ok_count,
"warnings": warn_count,
"errors": error_count,
}
}))
.into_response()
}
/// GET /api/memory — list or search memory entries
pub async fn handle_api_memory_list(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<MemoryQuery>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if let Some(ref query) = params.query {
// Search mode
match state.mem.recall(query, 50, None).await {
Ok(entries) => Json(serde_json::json!({"entries": entries})).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Memory recall failed: {e}")})),
)
.into_response(),
}
} else {
// List mode
let category = params.category.as_deref().map(|cat| match cat {
"core" => crate::memory::MemoryCategory::Core,
"daily" => crate::memory::MemoryCategory::Daily,
"conversation" => crate::memory::MemoryCategory::Conversation,
other => crate::memory::MemoryCategory::Custom(other.to_string()),
});
match state.mem.list(category.as_ref(), None).await {
Ok(entries) => Json(serde_json::json!({"entries": entries})).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Memory list failed: {e}")})),
)
.into_response(),
}
}
}
/// POST /api/memory — store a memory entry
pub async fn handle_api_memory_store(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<MemoryStoreBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let category = body
.category
.as_deref()
.map(|cat| match cat {
"core" => crate::memory::MemoryCategory::Core,
"daily" => crate::memory::MemoryCategory::Daily,
"conversation" => crate::memory::MemoryCategory::Conversation,
other => crate::memory::MemoryCategory::Custom(other.to_string()),
})
.unwrap_or(crate::memory::MemoryCategory::Core);
match state
.mem
.store(&body.key, &body.content, category, None)
.await
{
Ok(()) => Json(serde_json::json!({"status": "ok"})).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Memory store failed: {e}")})),
)
.into_response(),
}
}
/// DELETE /api/memory/:key — delete a memory entry
pub async fn handle_api_memory_delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(key): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
match state.mem.forget(&key).await {
Ok(deleted) => {
Json(serde_json::json!({"status": "ok", "deleted": deleted})).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Memory forget failed: {e}")})),
)
.into_response(),
}
}
/// GET /api/cost — cost summary
pub async fn handle_api_cost(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if let Some(ref tracker) = state.cost_tracker {
match tracker.get_summary() {
Ok(summary) => Json(serde_json::json!({"cost": summary})).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Cost summary failed: {e}")})),
)
.into_response(),
}
} else {
Json(serde_json::json!({
"cost": {
"session_cost_usd": 0.0,
"daily_cost_usd": 0.0,
"monthly_cost_usd": 0.0,
"total_tokens": 0,
"request_count": 0,
"by_model": {},
}
}))
.into_response()
}
}
/// GET /api/cli-tools — discovered CLI tools
pub async fn handle_api_cli_tools(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]);
Json(serde_json::json!({"cli_tools": tools})).into_response()
}
/// GET /api/health — component health snapshot
pub async fn handle_api_health(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let snapshot = crate::health::snapshot();
Json(serde_json::json!({"health": snapshot})).into_response()
}
// ── Helpers ─────────────────────────────────────────────────────
fn mask_sensitive_fields(toml_str: &str) -> String {
let mut output = String::with_capacity(toml_str.len());
for line in toml_str.lines() {
let trimmed = line.trim();
if trimmed.starts_with("api_key")
|| trimmed.starts_with("bot_token")
|| trimmed.starts_with("access_token")
|| trimmed.starts_with("secret")
|| trimmed.starts_with("app_secret")
|| trimmed.starts_with("signing_secret")
{
if let Some(eq_pos) = line.find('=') {
output.push_str(&line[..eq_pos + 1]);
output.push_str(" \"***MASKED***\"");
} else {
output.push_str(line);
}
} else {
output.push_str(line);
}
output.push('\n');
}
output
}