style: apply cargo fmt to all source files

This commit is contained in:
2026-03-09 20:42:18 -07:00
parent e539dff62f
commit ab52ede69a
6 changed files with 269 additions and 144 deletions
+1 -6
View File
@@ -1,6 +1 @@
-e target/ /target/
__pycache__/
node_modules/
*.pyc
.env
dist/
+41 -22
View File
@@ -2,10 +2,13 @@ mod config;
mod db; mod db;
mod models; mod models;
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHasher,
};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use argon2::{Argon2, PasswordHasher, password_hash::{SaltString, rand_core::OsRng}};
use uuid::Uuid;
use rand::Rng; use rand::Rng;
use uuid::Uuid;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "gateway-cli", about = "Action Gateway admin CLI")] #[command(name = "gateway-cli", about = "Action Gateway admin CLI")]
@@ -17,16 +20,11 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Add a user /// Add a user
AddUser { AddUser { username: String, password: String },
username: String,
password: String,
},
/// List all users /// List all users
ListUsers, ListUsers,
/// Remove a user /// Remove a user
RemoveUser { RemoveUser { username: String },
username: String,
},
/// Generate a new API key for the agent /// Generate a new API key for the agent
GenApiKey { GenApiKey {
#[arg(default_value = "agent")] #[arg(default_value = "agent")]
@@ -51,25 +49,38 @@ fn main() {
conn.execute( conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (?1, ?2, ?3)", "INSERT INTO users (id, username, password_hash) VALUES (?1, ?2, ?3)",
[&Uuid::new_v4().to_string(), &username, &hash], [&Uuid::new_v4().to_string(), &username, &hash],
).unwrap(); )
.unwrap();
println!("✅ User '{}' created.", username); println!("✅ User '{}' created.", username);
} }
Commands::ListUsers => { Commands::ListUsers => {
let mut stmt = conn.prepare("SELECT username, created_at FROM users ORDER BY created_at").unwrap(); let mut stmt = conn
let users: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() .prepare("SELECT username, created_at FROM users ORDER BY created_at")
.filter_map(|r| r.ok()).collect(); .unwrap();
let users: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
if users.is_empty() { if users.is_empty() {
println!("No users."); println!("No users.");
} else { } else {
println!("{:<20} {}", "Username", "Created"); println!("{:<20} {}", "Username", "Created");
println!("{}", "-".repeat(40)); println!("{}", "-".repeat(40));
for (u, c) in users { println!("{:<20} {}", u, c); } for (u, c) in users {
println!("{:<20} {}", u, c);
}
} }
} }
Commands::RemoveUser { username } => { Commands::RemoveUser { username } => {
let n = conn.execute("DELETE FROM users WHERE username = ?1", [&username]).unwrap(); let n = conn
if n > 0 { println!("✅ User '{}' removed.", username); } .execute("DELETE FROM users WHERE username = ?1", [&username])
else { println!("❌ User '{}' not found.", username); } .unwrap();
if n > 0 {
println!("✅ User '{}' removed.", username);
} else {
println!("❌ User '{}' not found.", username);
}
} }
Commands::GenApiKey { label } => { Commands::GenApiKey { label } => {
// Generate random key // Generate random key
@@ -86,21 +97,29 @@ fn main() {
conn.execute( conn.execute(
"INSERT INTO api_keys (id, key_hash, label) VALUES (?1, ?2, ?3)", "INSERT INTO api_keys (id, key_hash, label) VALUES (?1, ?2, ?3)",
[&Uuid::new_v4().to_string(), &hash, &label], [&Uuid::new_v4().to_string(), &hash, &label],
).unwrap(); )
.unwrap();
println!("✅ API key generated for '{}':", label); println!("✅ API key generated for '{}':", label);
println!("\n {}\n", key); println!("\n {}\n", key);
println!("Store this key securely — it won't be shown again."); println!("Store this key securely — it won't be shown again.");
} }
Commands::ListKeys => { Commands::ListKeys => {
let mut stmt = conn.prepare("SELECT label, created_at FROM api_keys ORDER BY created_at").unwrap(); let mut stmt = conn
let keys: Vec<(String, String)> = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() .prepare("SELECT label, created_at FROM api_keys ORDER BY created_at")
.filter_map(|r| r.ok()).collect(); .unwrap();
let keys: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
if keys.is_empty() { if keys.is_empty() {
println!("No API keys."); println!("No API keys.");
} else { } else {
println!("{:<20} {}", "Label", "Created"); println!("{:<20} {}", "Label", "Created");
println!("{}", "-".repeat(40)); println!("{}", "-".repeat(40));
for (l, c) in keys { println!("{:<20} {}", l, c); } for (l, c) in keys {
println!("{:<20} {}", l, c);
}
} }
} }
} }
+3 -1
View File
@@ -33,7 +33,9 @@ impl Config {
"/home/node/.openclaw/workspace/config/action-gateway.toml".into(), "/home/node/.openclaw/workspace/config/action-gateway.toml".into(),
]; ];
for path in &paths { for path in &paths {
if path.is_empty() { continue; } if path.is_empty() {
continue;
}
if let Ok(contents) = fs::read_to_string(path) { if let Ok(contents) = fs::read_to_string(path) {
return toml::from_str(&contents).unwrap_or_default(); return toml::from_str(&contents).unwrap_or_default();
} }
+4 -2
View File
@@ -8,7 +8,8 @@ pub fn open(path: &str) -> Result<Connection> {
} }
fn migrate(conn: &Connection) -> Result<()> { fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(" conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
@@ -40,5 +41,6 @@ fn migrate(conn: &Connection) -> Result<()> {
submitted_at TEXT NOT NULL DEFAULT (datetime('now')), submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT resolved_at TEXT
); );
") ",
)
} }
+2 -3
View File
@@ -3,10 +3,9 @@ mod db;
mod models; mod models;
mod routes; mod routes;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use config::Config; use config::Config;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
+175 -67
View File
@@ -1,19 +1,18 @@
use axum::{
Router,
routing::{get, post},
extract::{State, Path, Query, Form},
response::{Html, Redirect, IntoResponse},
http::{StatusCode, HeaderMap},
Json,
};
use axum_extra::extract::cookie::{CookieJar, Cookie};
use serde::Deserialize;
use uuid::Uuid;
use argon2::{Argon2, PasswordHash, PasswordVerifier}; use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::{
extract::{Form, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
Json, Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc; use chrono::Utc;
use serde::Deserialize;
use std::process::Command; use std::process::Command;
use uuid::Uuid;
use crate::{AppState, models::*}; use crate::{models::*, AppState};
pub fn router(state: AppState) -> Router { pub fn router(state: AppState) -> Router {
Router::new() Router::new()
@@ -40,16 +39,20 @@ fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
WHERE s.token = ?1 AND s.expires_at > datetime('now')", WHERE s.token = ?1 AND s.expires_at > datetime('now')",
[&token], [&token],
|row| row.get(0), |row| row.get(0),
).ok() )
.ok()
} }
fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool { fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
let auth = headers.get("authorization") let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ")) .and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
if auth.is_empty() { return false; } if auth.is_empty() {
return false;
}
let db = state.db.lock().unwrap(); let db = state.db.lock().unwrap();
let keys: Vec<String> = { let keys: Vec<String> = {
let mut stmt = db.prepare("SELECT key_hash FROM api_keys").unwrap(); let mut stmt = db.prepare("SELECT key_hash FROM api_keys").unwrap();
@@ -60,14 +63,22 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
}; };
keys.iter().any(|hash| { keys.iter().any(|hash| {
let parsed = PasswordHash::new(hash).ok(); let parsed = PasswordHash::new(hash).ok();
parsed.map(|h| Argon2::default().verify_password(auth.as_bytes(), &h).is_ok()).unwrap_or(false) parsed
.map(|h| {
Argon2::default()
.verify_password(auth.as_bytes(), &h)
.is_ok()
})
.unwrap_or(false)
}) })
} }
// ── Web UI ──────────────────────────────────────────────────────── // ── Web UI ────────────────────────────────────────────────────────
async fn login_page() -> Html<String> { async fn login_page() -> Html<String> {
Html(page("Login", r#" Html(page(
"Login",
r#"
<div class="card" style="max-width:400px;margin:80px auto"> <div class="card" style="max-width:400px;margin:80px auto">
<h2>🔐 Action Gateway</h2> <h2>🔐 Action Gateway</h2>
<form method="post" action="/login"> <form method="post" action="/login">
@@ -78,7 +89,8 @@ async fn login_page() -> Html<String> {
<button type="submit">Sign In</button> <button type="submit">Sign In</button>
</form> </form>
</div> </div>
"#)) "#,
))
} }
async fn login_post( async fn login_post(
@@ -92,20 +104,27 @@ async fn login_post(
"SELECT id, password_hash FROM users WHERE username = ?1", "SELECT id, password_hash FROM users WHERE username = ?1",
[&form.username], [&form.username],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
).ok() )
.ok()
}; };
if let Some((user_id, hash)) = result { if let Some((user_id, hash)) = result {
let parsed = PasswordHash::new(&hash).unwrap(); let parsed = PasswordHash::new(&hash).unwrap();
if Argon2::default().verify_password(form.password.as_bytes(), &parsed).is_ok() { if Argon2::default()
.verify_password(form.password.as_bytes(), &parsed)
.is_ok()
{
let token = Uuid::new_v4().to_string(); let token = Uuid::new_v4().to_string();
let expires = (Utc::now() + chrono::Duration::days(7)).format("%Y-%m-%d %H:%M:%S").to_string(); let expires = (Utc::now() + chrono::Duration::days(7))
.format("%Y-%m-%d %H:%M:%S")
.to_string();
{ {
let db = state.db.lock().unwrap(); let db = state.db.lock().unwrap();
db.execute( db.execute(
"INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?1, ?2, ?3, ?4)",
[&Uuid::new_v4().to_string(), &user_id, &token, &expires], [&Uuid::new_v4().to_string(), &user_id, &token, &expires],
).unwrap(); )
.unwrap();
} }
let cookie = Cookie::build(("session", token)) let cookie = Cookie::build(("session", token))
.path("/") .path("/")
@@ -114,7 +133,9 @@ async fn login_post(
return (jar.add(cookie), Redirect::to("/")).into_response(); return (jar.add(cookie), Redirect::to("/")).into_response();
} }
} }
Html(page("Login", r#" Html(page(
"Login",
r#"
<div class="card" style="max-width:400px;margin:80px auto"> <div class="card" style="max-width:400px;margin:80px auto">
<h2>🔐 Action Gateway</h2> <h2>🔐 Action Gateway</h2>
<p style="color:#e74c3c">Invalid credentials.</p> <p style="color:#e74c3c">Invalid credentials.</p>
@@ -126,7 +147,9 @@ async fn login_post(
<button type="submit">Sign In</button> <button type="submit">Sign In</button>
</form> </form>
</div> </div>
"#)).into_response() "#,
))
.into_response()
} }
async fn logout(jar: CookieJar) -> impl IntoResponse { async fn logout(jar: CookieJar) -> impl IntoResponse {
@@ -134,10 +157,7 @@ async fn logout(jar: CookieJar) -> impl IntoResponse {
(jar, Redirect::to("/login")) (jar, Redirect::to("/login"))
} }
async fn dashboard( async fn dashboard(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
let Some(username) = session_user(&jar, &state) else { let Some(username) = session_user(&jar, &state) else {
return Redirect::to("/login").into_response(); return Redirect::to("/login").into_response();
}; };
@@ -148,7 +168,8 @@ async fn dashboard(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at "SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at
FROM actions ORDER BY submitted_at DESC LIMIT 50" FROM actions ORDER BY submitted_at DESC LIMIT 50"
).unwrap(); ).unwrap();
stmt.query_map([], |row| Ok(Action { stmt.query_map([], |row| {
Ok(Action {
id: row.get(0)?, id: row.get(0)?,
chain_id: row.get(1)?, chain_id: row.get(1)?,
sequence: row.get(2)?, sequence: row.get(2)?,
@@ -160,13 +181,16 @@ async fn dashboard(
approve_token: row.get(8)?, approve_token: row.get(8)?,
submitted_at: row.get(9)?, submitted_at: row.get(9)?,
resolved_at: row.get(10)?, resolved_at: row.get(10)?,
})) })
})
.unwrap() .unwrap()
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect() .collect()
}; };
let rows: String = actions.iter().map(|a| { let rows: String = actions
.iter()
.map(|a| {
let status_badge = match a.status.as_str() { let status_badge = match a.status.as_str() {
"pending" => r#"<span class="badge pending">⏳ Pending</span>"#, "pending" => r#"<span class="badge pending">⏳ Pending</span>"#,
"approved" => r#"<span class="badge approved">✅ Approved</span>"#, "approved" => r#"<span class="badge approved">✅ Approved</span>"#,
@@ -176,21 +200,30 @@ async fn dashboard(
_ => r#"<span class="badge">Unknown</span>"#, _ => r#"<span class="badge">Unknown</span>"#,
}; };
let actions_html = if a.status == "pending" { let actions_html = if a.status == "pending" {
format!(r#" format!(
r#"
<form method="post" action="/actions/{}/approve" style="display:inline"> <form method="post" action="/actions/{}/approve" style="display:inline">
<button class="btn-approve" type="submit">✅ Approve</button> <button class="btn-approve" type="submit">✅ Approve</button>
</form> </form>
<form method="post" action="/actions/{}/reject" style="display:inline"> <form method="post" action="/actions/{}/reject" style="display:inline">
<button class="btn-reject" type="submit">❌ Reject</button> <button class="btn-reject" type="submit">❌ Reject</button>
</form> </form>
"#, a.id, a.id) "#,
a.id, a.id
)
} else { } else {
a.result.clone().map(|r| format!("<pre class='result'>{}</pre>", html_escape(&r))).unwrap_or_default() a.result
.clone()
.map(|r| format!("<pre class='result'>{}</pre>", html_escape(&r)))
.unwrap_or_default()
}; };
let run_as_badge = a.run_as.as_ref() let run_as_badge = a
.run_as
.as_ref()
.map(|u| format!("<span class='run-as'>run as: {}</span>", u)) .map(|u| format!("<span class='run-as'>run as: {}</span>", u))
.unwrap_or_default(); .unwrap_or_default();
format!(r#" format!(
r#"
<div class="action-card"> <div class="action-card">
<div class="action-header"> <div class="action-header">
{} {} {} {} {} {}
@@ -200,16 +233,23 @@ async fn dashboard(
<pre class="command">$ {}</pre> <pre class="command">$ {}</pre>
{} {}
</div> </div>
"#, status_badge, "#,
a.chain_id.as_ref().map(|c| format!("<span class='chain'>chain: {}</span>", &c[..8])).unwrap_or_default(), status_badge,
a.chain_id
.as_ref()
.map(|c| format!("<span class='chain'>chain: {}</span>", &c[..8]))
.unwrap_or_default(),
run_as_badge, run_as_badge,
a.submitted_at, a.submitted_at,
html_escape(&a.description), html_escape(&a.description),
html_escape(&a.command), html_escape(&a.command),
actions_html) actions_html
}).collect(); )
})
.collect();
let content = format!(r#" let content = format!(
r#"
<div class="header"> <div class="header">
<h1>🤖 Action Gateway</h1> <h1>🤖 Action Gateway</h1>
<span>Logged in as <strong>{}</strong> &nbsp; <a href="/logout">Logout</a></span> <span>Logged in as <strong>{}</strong> &nbsp; <a href="/logout">Logout</a></span>
@@ -217,7 +257,14 @@ async fn dashboard(
<div class="actions-list" hx-get="/" hx-trigger="every 5s" hx-swap="outerHTML" hx-select=".actions-list"> <div class="actions-list" hx-get="/" hx-trigger="every 5s" hx-swap="outerHTML" hx-select=".actions-list">
{} {}
</div> </div>
"#, username, if rows.is_empty() { "<p style='text-align:center;opacity:0.5'>No actions yet.</p>".into() } else { rows }); "#,
username,
if rows.is_empty() {
"<p style='text-align:center;opacity:0.5'>No actions yet.</p>".into()
} else {
rows
}
);
Html(page("Action Gateway", &content)).into_response() Html(page("Action Gateway", &content)).into_response()
} }
@@ -250,7 +297,8 @@ async fn action_detail(
}; };
if let Some(a) = action { if let Some(a) = action {
if a.status == "pending" { if a.status == "pending" {
let content = format!(r#" let content = format!(
r#"
<div class="card" style="max-width:600px;margin:40px auto"> <div class="card" style="max-width:600px;margin:40px auto">
<h2>Review Action</h2> <h2>Review Action</h2>
<p>{}</p> <p>{}</p>
@@ -262,7 +310,12 @@ async fn action_detail(
<button class="btn-reject">❌ Reject</button> <button class="btn-reject">❌ Reject</button>
</form> </form>
</div> </div>
"#, html_escape(&a.description), html_escape(&a.command), a.id, a.id); "#,
html_escape(&a.description),
html_escape(&a.command),
a.id,
a.id
);
return Html(page("Approve Action", &content)).into_response(); return Html(page("Approve Action", &content)).into_response();
} }
} }
@@ -303,8 +356,12 @@ async fn approve_action(
if a.status == "pending" { if a.status == "pending" {
// Build command with run_as support // Build command with run_as support
let output = match a.run_as.as_deref() { let output = match a.run_as.as_deref() {
Some("root") => Command::new("sudo").args(["-n", "sh", "-c", &a.command]).output(), Some("root") => Command::new("sudo")
Some(user) => Command::new("sudo").args(["-n", "-u", user, "sh", "-c", &a.command]).output(), .args(["-n", "sh", "-c", &a.command])
.output(),
Some(user) => Command::new("sudo")
.args(["-n", "-u", user, "sh", "-c", &a.command])
.output(),
None => Command::new("sh").args(["-c", &a.command]).output(), None => Command::new("sh").args(["-c", &a.command]).output(),
}; };
@@ -312,8 +369,16 @@ async fn approve_action(
Ok(out) => { Ok(out) => {
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout)); let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr)); let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
let combined = if stderr.is_empty() { stdout } else { format!("{}\nSTDERR:\n{}", stdout, stderr) }; let combined = if stderr.is_empty() {
if out.status.success() { ("executed", combined) } else { ("failed", combined) } stdout
} else {
format!("{}\nSTDERR:\n{}", stdout, stderr)
};
if out.status.success() {
("executed", combined)
} else {
("failed", combined)
}
} }
Err(e) => ("failed", e.to_string()), Err(e) => ("failed", e.to_string()),
}; };
@@ -341,14 +406,19 @@ async fn reject_action(
// Reject this action and all pending actions in same chain // Reject this action and all pending actions in same chain
let chain_id: Option<String> = { let chain_id: Option<String> = {
let db = state.db.lock().unwrap(); let db = state.db.lock().unwrap();
db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| row.get(0)).ok().flatten() db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| {
row.get(0)
})
.ok()
.flatten()
}; };
let db = state.db.lock().unwrap(); let db = state.db.lock().unwrap();
db.execute( db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1", "UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
[&id], [&id],
).unwrap(); )
.unwrap();
if let Some(cid) = chain_id { if let Some(cid) = chain_id {
db.execute( db.execute(
@@ -368,7 +438,11 @@ async fn submit_action(
Json(body): Json<SubmitAction>, Json(body): Json<SubmitAction>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if !verify_api_key(&headers, &state) { if !verify_api_key(&headers, &state) {
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response(); return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
)
.into_response();
} }
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
@@ -385,7 +459,10 @@ async fn submit_action(
} }
// Send notification webhook // Send notification webhook
let approve_url = format!("{}/actions/{}?token={}", state.config.base_url, id, approve_token); let approve_url = format!(
"{}/actions/{}?token={}",
state.config.base_url, id, approve_token
);
if let Some(webhook) = &state.config.notification_webhook { if let Some(webhook) = &state.config.notification_webhook {
let webhook = webhook.clone(); let webhook = webhook.clone();
let token = state.config.notification_webhook_token.clone(); let token = state.config.notification_webhook_token.clone();
@@ -406,11 +483,15 @@ async fn submit_action(
}); });
} }
(StatusCode::CREATED, Json(serde_json::json!({ (
StatusCode::CREATED,
Json(serde_json::json!({
"id": id, "id": id,
"approve_url": approve_url, "approve_url": approve_url,
"status": "pending" "status": "pending"
}))).into_response() })),
)
.into_response()
} }
async fn get_result( async fn get_result(
@@ -419,14 +500,20 @@ async fn get_result(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if !verify_api_key(&headers, &state) { if !verify_api_key(&headers, &state) {
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))).into_response(); return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
)
.into_response();
} }
let db = state.db.lock().unwrap(); let db = state.db.lock().unwrap();
let result: Option<(String, Option<String>)> = db.query_row( let result: Option<(String, Option<String>)> = db
.query_row(
"SELECT status, result FROM actions WHERE id = ?1", "SELECT status, result FROM actions WHERE id = ?1",
[&id], [&id],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
).ok(); )
.ok();
match result { match result {
Some((status, result)) => { Some((status, result)) => {
@@ -434,7 +521,9 @@ async fn get_result(
// newlines/control chars in Option<String> in this build environment. // newlines/control chars in Option<String> in this build environment.
let result_json = match result { let result_json = match result {
Some(s) => { Some(s) => {
let escaped: String = s.chars().flat_map(|c| match c { let escaped: String = s
.chars()
.flat_map(|c| match c {
'"' => vec!['\\', '"'], '"' => vec!['\\', '"'],
'\\' => vec!['\\', '\\'], '\\' => vec!['\\', '\\'],
'\n' => vec!['\\', 'n'], '\n' => vec!['\\', 'n'],
@@ -444,7 +533,8 @@ async fn get_result(
format!("\\u{:04x}", c as u32).chars().collect() format!("\\u{:04x}", c as u32).chars().collect()
} }
c => vec![c], c => vec![c],
}).collect(); })
.collect();
format!("\"{}\"", escaped) format!("\"{}\"", escaped)
} }
None => "null".to_string(), None => "null".to_string(),
@@ -454,16 +544,24 @@ async fn get_result(
StatusCode::OK, StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")], [(axum::http::header::CONTENT_TYPE, "application/json")],
body, body,
).into_response() )
.into_response()
} }
None => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))).into_response(), None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "not found"})),
)
.into_response(),
} }
} }
// ── HTML helpers ────────────────────────────────────────────────── // ── HTML helpers ──────────────────────────────────────────────────
fn html_escape(s: &str) -> String { fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
} }
/// Strip ANSI/VT100 escape sequences and other control characters from command output. /// Strip ANSI/VT100 escape sequences and other control characters from command output.
@@ -481,13 +579,20 @@ fn sanitize_output(s: &str) -> String {
Some('[') => { Some('[') => {
chars.next(); chars.next();
for nc in chars.by_ref() { for nc in chars.by_ref() {
if nc.is_ascii_alphabetic() { break; } if nc.is_ascii_alphabetic() {
break;
}
} }
} }
// Character set designations: ESC ( X or ESC ) X // Character set designations: ESC ( X or ESC ) X
Some('(' | ')') => { chars.next(); chars.next(); } Some('(' | ')') => {
chars.next();
chars.next();
}
// Any other ESC sequence: skip one char // Any other ESC sequence: skip one char
_ => { chars.next(); } _ => {
chars.next();
}
} }
} }
// CR: collapse \r\n → \n, standalone \r → \n // CR: collapse \r\n → \n, standalone \r → \n
@@ -508,7 +613,8 @@ fn sanitize_output(s: &str) -> String {
} }
fn page(title: &str, content: &str) -> String { fn page(title: &str, content: &str) -> String {
format!(r#"<!DOCTYPE html> format!(
r#"<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -553,5 +659,7 @@ fn page(title: &str, content: &str) -> String {
<body> <body>
{} {}
</body> </body>
</html>"#, title, content) </html>"#,
title, content
)
} }