botserver/src/basic/keywords/app_server.rs

199 lines
6.3 KiB
Rust

use crate::shared::state::AppState;
use axum::{
body::Body,
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use log::{error, trace, warn};
use std::sync::Arc;
pub fn configure_app_server_routes() -> Router<Arc<AppState>> {
Router::new()
// Serve app files: /apps/{app_name}/* (clean URLs)
.route("/apps/:app_name", get(serve_app_index))
.route("/apps/:app_name/", get(serve_app_index))
.route("/apps/:app_name/*file_path", get(serve_app_file))
// List all available apps
.route("/apps", get(list_all_apps))
}
#[derive(Debug, serde::Deserialize)]
pub struct AppPath {
pub app_name: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct AppFilePath {
pub app_name: String,
pub file_path: String,
}
pub async fn serve_app_index(
State(state): State<Arc<AppState>>,
Path(params): Path<AppPath>,
) -> impl IntoResponse {
serve_app_file_internal(&state, &params.app_name, "index.html")
}
pub async fn serve_app_file(
State(state): State<Arc<AppState>>,
Path(params): Path<AppFilePath>,
) -> impl IntoResponse {
serve_app_file_internal(&state, &params.app_name, &params.file_path)
}
fn serve_app_file_internal(state: &AppState, app_name: &str, file_path: &str) -> Response {
let sanitized_app_name = sanitize_path_component(app_name);
let sanitized_file_path = sanitize_path_component(file_path);
if sanitized_app_name.is_empty() || sanitized_file_path.is_empty() {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let site_path = state
.config
.as_ref()
.map(|c| c.site_path.clone())
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
let full_path = format!(
"{}/{}/{}",
site_path, sanitized_app_name, sanitized_file_path
);
trace!("Serving app file: {full_path}");
let path = std::path::Path::new(&full_path);
if !path.exists() {
warn!("App file not found: {full_path}");
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let content_type = get_content_type(&sanitized_file_path);
match std::fs::read(&full_path) {
Ok(contents) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(contents))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build response",
)
.into_response()
}),
Err(e) => {
error!("Failed to read file {full_path}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
}
}
}
pub async fn list_all_apps(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let site_path = state
.config
.as_ref()
.map(|c| c.site_path.clone())
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
let mut apps = Vec::new();
if let Ok(entries) = std::fs::read_dir(&site_path) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if let Some(name) = entry.file_name().to_str() {
if name.starts_with('.') || name.to_lowercase().ends_with(".gbai") {
continue;
}
let app_path = entry.path();
let has_index = app_path.join("index.html").exists();
if has_index {
apps.push(serde_json::json!({
"name": name,
"url": format!("/apps/{}", name),
"has_index": true
}));
}
}
}
}
}
(
StatusCode::OK,
axum::Json(serde_json::json!({
"apps": apps,
"count": apps.len()
})),
)
.into_response()
}
fn sanitize_path_component(component: &str) -> String {
component
.replace("..", "")
.replace("//", "/")
.trim_start_matches('/')
.trim_end_matches('/')
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.' || *c == '/')
.collect()
}
fn get_content_type(file_path: &str) -> &'static str {
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"eot" => "application/vnd.ms-fontobject",
"txt" => "text/plain; charset=utf-8",
"xml" => "application/xml; charset=utf-8",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_path_component() {
assert_eq!(sanitize_path_component("clinic"), "clinic");
assert_eq!(sanitize_path_component("../etc/passwd"), "etc/passwd");
assert_eq!(sanitize_path_component("app/../secret"), "app/secret");
assert_eq!(sanitize_path_component("/leading/slash"), "leading/slash");
assert_eq!(sanitize_path_component("file.html"), "file.html");
assert_eq!(sanitize_path_component("my-app_v2"), "my-app_v2");
}
#[test]
fn test_get_content_type() {
assert_eq!(get_content_type("index.html"), "text/html; charset=utf-8");
assert_eq!(get_content_type("styles.css"), "text/css; charset=utf-8");
assert_eq!(
get_content_type("app.js"),
"application/javascript; charset=utf-8"
);
assert_eq!(get_content_type("image.png"), "image/png");
assert_eq!(get_content_type("unknown.xyz"), "application/octet-stream");
}
}