536 lines
16 KiB
Rust
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
|
|
}
|