Public Access
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6fb18318e | ||
|
|
a0a9a1024d | ||
|
|
8e3dcb735e | ||
|
|
18b7a8f5de | ||
|
|
ab52ede69a | ||
|
|
e539dff62f | ||
|
|
c94735ff70 | ||
|
|
aa7fae8d28 | ||
|
|
94a4398e66 | ||
|
|
216f0f0299 | ||
|
|
8621cb7be2 | ||
|
|
06ae7c0a66 | ||
|
|
4ccc7f8249 | ||
|
|
225a681eb1 | ||
|
|
071a862152 | ||
|
|
b7bb24fb70 |
@@ -0,0 +1,72 @@
|
|||||||
|
name: Action Gateway CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags:
|
||||||
|
- 'release/*'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build & Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Rust with rustfmt and clippy
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile default
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
rustup component add rustfmt clippy
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Publish Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-and-test
|
||||||
|
if: startsWith(github.ref, 'refs/tags/release/')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile default
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
|
||||||
|
- name: Build release binary
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Create Gitea release with binary
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
RELEASE=$(curl -s -X POST "https://git.dominat.us/api/v1/repos/clawbot/action-gateway/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.BOT_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$TAG\",
|
||||||
|
\"name\": \"Action Gateway $TAG\",
|
||||||
|
\"body\": \"Automated release
|
||||||
|
|
||||||
|
Commit: ${{ github.sha }}\",
|
||||||
|
\"draft\": false
|
||||||
|
}")
|
||||||
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
curl -s -X POST "https://git.dominat.us/api/v1/repos/clawbot/action-gateway/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "Authorization: token ${{ secrets.BOT_TOKEN }}" \
|
||||||
|
-F "attachment=@target/release/action-gateway;filename=action-gateway-linux-amd64"
|
||||||
|
|
||||||
|
echo "Release $TAG created: $RELEASE_ID"
|
||||||
+1
-6
@@ -1,6 +1 @@
|
|||||||
-e target/
|
/target/
|
||||||
__pycache__/
|
|
||||||
node_modules/
|
|
||||||
*.pyc
|
|
||||||
.env
|
|
||||||
dist/
|
|
||||||
|
|||||||
+41
-22
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,9 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub database_path: String,
|
pub database_path: String,
|
||||||
pub bind: String,
|
pub bind: String,
|
||||||
@@ -31,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use rusqlite::{Connection, Result, params};
|
use rusqlite::{Connection, Result};
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
pub fn open(path: &str) -> Result<Connection> {
|
pub fn open(path: &str) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
@@ -9,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,
|
||||||
@@ -41,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
@@ -3,10 +3,9 @@ mod db;
|
|||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use axum::{Router, middleware};
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct Action {
|
pub struct Action {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub chain_id: Option<String>,
|
pub chain_id: Option<String>,
|
||||||
@@ -16,6 +17,7 @@ pub struct Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct SubmitAction {
|
pub struct SubmitAction {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
@@ -25,7 +27,9 @@ pub struct SubmitAction {
|
|||||||
pub run_as: Option<String>,
|
pub run_as: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ActionResult {
|
pub struct ActionResult {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
@@ -33,6 +37,7 @@ pub struct ActionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|||||||
+201
-67
@@ -1,22 +1,23 @@
|
|||||||
use axum::{
|
|
||||||
Router,
|
|
||||||
routing::{get, post},
|
|
||||||
extract::{State, Path, Query, Form},
|
|
||||||
response::{Html, Redirect, IntoResponse, Response},
|
|
||||||
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()
|
||||||
|
// Health check (no auth)
|
||||||
|
.route("/health", get(health_check))
|
||||||
// Web UI
|
// Web UI
|
||||||
.route("/", get(dashboard))
|
.route("/", get(dashboard))
|
||||||
.route("/login", get(login_page).post(login_post))
|
.route("/login", get(login_page).post(login_post))
|
||||||
@@ -30,6 +31,30 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Health Check ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
// Verify the database is accessible with a simple query
|
||||||
|
let db_ok = {
|
||||||
|
let db = state.db.lock().unwrap();
|
||||||
|
db.query_row("SELECT 1", [], |_| Ok(())).is_ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
if db_ok {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({"ok": true, "status": "live"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({"ok": false, "status": "database unreachable"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
|
fn session_user(jar: &CookieJar, state: &AppState) -> Option<String> {
|
||||||
@@ -40,16 +65,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 +89,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 +115,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 +130,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 +159,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 +173,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 +183,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 +194,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 +207,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 +226,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 +259,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> <a href="/logout">Logout</a></span>
|
<span>Logged in as <strong>{}</strong> <a href="/logout">Logout</a></span>
|
||||||
@@ -217,7 +283,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 +323,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 +336,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 +382,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 +395,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 +432,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 +464,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 +485,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 +509,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 +526,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 +547,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 +559,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 +570,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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +605,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 +639,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 +685,7 @@ fn page(title: &str, content: &str) -> String {
|
|||||||
<body>
|
<body>
|
||||||
{}
|
{}
|
||||||
</body>
|
</body>
|
||||||
</html>"#, title, content)
|
</html>"#,
|
||||||
|
title, content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user