Skip to main content

statespace_server/
server.rs

1//! HTTP server and Axum router.
2
3use crate::content::{ContentResolver, LocalContentResolver};
4use crate::error::ErrorExt;
5use crate::templates::FAVICON_SVG;
6use axum::{
7    Json, Router,
8    extract::{Path, State},
9    http::{StatusCode, header},
10    response::{Html, IntoResponse, Response},
11    routing::get,
12};
13use statespace_tool_runtime::{
14    ActionRequest, ActionResponse, BuiltinTool, ExecutionLimits, ToolExecutor, expand_env_vars,
15    expand_placeholders, parse_frontmatter, validate_command_with_specs,
16};
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::fs;
20use tower_http::cors::{Any, CorsLayer};
21use tower_http::trace::TraceLayer;
22use tracing::{info, warn};
23
24#[derive(Debug, Clone)]
25pub struct ServerConfig {
26    pub content_root: PathBuf,
27    pub host: String,
28    pub port: u16,
29    pub limits: ExecutionLimits,
30}
31
32impl ServerConfig {
33    #[must_use]
34    pub fn new(content_root: PathBuf) -> Self {
35        Self {
36            content_root,
37            host: "127.0.0.1".to_string(),
38            port: 8000,
39            limits: ExecutionLimits::default(),
40        }
41    }
42
43    #[must_use]
44    pub fn with_host(mut self, host: impl Into<String>) -> Self {
45        self.host = host.into();
46        self
47    }
48
49    #[must_use]
50    pub const fn with_port(mut self, port: u16) -> Self {
51        self.port = port;
52        self
53    }
54
55    #[must_use]
56    pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
57        self.limits = limits;
58        self
59    }
60
61    #[must_use]
62    pub fn socket_addr(&self) -> String {
63        format!("{}:{}", self.host, self.port)
64    }
65
66    #[must_use]
67    pub fn base_url(&self) -> String {
68        format!("http://{}:{}", self.host, self.port)
69    }
70}
71
72#[derive(Clone)]
73pub struct ServerState {
74    pub content_resolver: Arc<dyn ContentResolver>,
75    pub limits: ExecutionLimits,
76    pub content_root: PathBuf,
77}
78
79impl std::fmt::Debug for ServerState {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("ServerState")
82            .field("limits", &self.limits)
83            .field("content_root", &self.content_root)
84            .finish_non_exhaustive()
85    }
86}
87
88impl ServerState {
89    /// # Errors
90    ///
91    /// Returns an error if the content root path cannot be canonicalized.
92    pub fn from_config(config: &ServerConfig) -> crate::error::Result<Self> {
93        Ok(Self {
94            content_resolver: Arc::new(LocalContentResolver::new(&config.content_root)?),
95            limits: config.limits.clone(),
96            content_root: config.content_root.clone(),
97        })
98    }
99}
100
101/// # Errors
102///
103/// Returns an error if the content root path cannot be canonicalized.
104pub fn build_router(config: &ServerConfig) -> crate::error::Result<Router> {
105    let state = ServerState::from_config(config)?;
106
107    let cors = CorsLayer::new()
108        .allow_origin(Any)
109        .allow_methods(Any)
110        .allow_headers(Any);
111
112    Ok(Router::new()
113        .route("/", get(index_handler).post(action_handler_root))
114        .route("/favicon.svg", get(favicon_handler))
115        .route("/favicon.ico", get(favicon_handler))
116        .route("/{*path}", get(file_handler).post(action_handler))
117        .layer(cors)
118        .layer(TraceLayer::new_for_http())
119        .with_state(state))
120}
121
122async fn index_handler(State(state): State<ServerState>) -> Response {
123    let index_path = state.content_root.join("index.html");
124
125    if index_path.is_file() {
126        match fs::read_to_string(&index_path).await {
127            Ok(content) => {
128                return (
129                    StatusCode::OK,
130                    [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
131                    content,
132                )
133                    .into_response();
134            }
135            Err(e) => {
136                warn!("Failed to read index.html: {}", e);
137            }
138        }
139    }
140
141    serve_markdown("", &state).await
142}
143
144async fn favicon_handler(State(state): State<ServerState>) -> Response {
145    let favicon_path = state.content_root.join("favicon.svg");
146
147    let content = if favicon_path.is_file() {
148        fs::read_to_string(&favicon_path)
149            .await
150            .unwrap_or_else(|_| FAVICON_SVG.to_string())
151    } else {
152        FAVICON_SVG.to_string()
153    };
154
155    (
156        StatusCode::OK,
157        [(header::CONTENT_TYPE, "image/svg+xml")],
158        content,
159    )
160        .into_response()
161}
162
163async fn file_handler(Path(path): Path<String>, State(state): State<ServerState>) -> Response {
164    serve_markdown(&path, &state).await
165}
166
167async fn serve_markdown(path: &str, state: &ServerState) -> Response {
168    match state.content_resolver.resolve(path).await {
169        Ok(content) => Html(content).into_response(),
170        Err(e) => {
171            warn!("File not found: {} ({})", path, e);
172            (e.status_code(), e.user_message()).into_response()
173        }
174    }
175}
176
177async fn action_handler_root(
178    State(state): State<ServerState>,
179    Json(request): Json<ActionRequest>,
180) -> Response {
181    execute_action("", &state, request).await
182}
183
184async fn action_handler(
185    Path(path): Path<String>,
186    State(state): State<ServerState>,
187    Json(request): Json<ActionRequest>,
188) -> Response {
189    execute_action(&path, &state, request).await
190}
191
192fn error_to_action_response(e: &statespace_tool_runtime::Error) -> Response {
193    let status = e.status_code();
194    let response = ActionResponse::error(e.user_message());
195    (status, Json(response)).into_response()
196}
197
198async fn execute_action(path: &str, state: &ServerState, request: ActionRequest) -> Response {
199    if let Err(msg) = request.validate() {
200        return error_response(StatusCode::BAD_REQUEST, &msg);
201    }
202
203    let file_path = match state.content_resolver.resolve_path(path).await {
204        Ok(p) => p,
205        Err(e) => return error_to_action_response(&e),
206    };
207
208    let content = match state.content_resolver.resolve(path).await {
209        Ok(c) => c,
210        Err(e) => return error_to_action_response(&e),
211    };
212
213    let frontmatter = match parse_frontmatter(&content) {
214        Ok(fm) => fm,
215        Err(e) => return error_to_action_response(&e),
216    };
217
218    let expanded_command = expand_placeholders(&request.command, &request.args);
219    let expanded_command = expand_env_vars(&expanded_command, &request.env);
220
221    if let Err(e) = validate_command_with_specs(&frontmatter.specs, &expanded_command) {
222        warn!(
223            "Command not allowed by frontmatter: {:?} (file: {})",
224            expanded_command, path
225        );
226        return error_to_action_response(&e);
227    }
228
229    let tool = match BuiltinTool::from_command(&expanded_command) {
230        Ok(t) => t,
231        Err(e) => {
232            warn!("Unknown tool: {}", e);
233            return error_to_action_response(&e);
234        }
235    };
236
237    let working_dir = file_path.parent().unwrap_or(&file_path);
238    let executor = ToolExecutor::new(working_dir.to_path_buf(), state.limits.clone());
239
240    info!("Executing tool: {:?}", tool);
241
242    match executor.execute(&tool).await {
243        Ok(output) => {
244            let response = ActionResponse::success(output.to_text());
245            (StatusCode::OK, Json(response)).into_response()
246        }
247        Err(e) => {
248            let status = e.status_code();
249            let response = ActionResponse::error(e.user_message());
250            (status, Json(response)).into_response()
251        }
252    }
253}
254
255fn error_response(status: StatusCode, message: &str) -> Response {
256    let response = ActionResponse::error(message.to_string());
257    (status, Json(response)).into_response()
258}