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> { 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>, Path(params): Path, ) -> impl IntoResponse { serve_app_file_internal(&state, ¶ms.app_name, "index.html") } pub async fn serve_app_file( State(state): State>, Path(params): Path, ) -> impl IntoResponse { serve_app_file_internal(&state, ¶ms.app_name, ¶ms.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>) -> 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"); } }