diff --git a/Cargo.toml b/Cargo.toml index 528ed7639..6ca3331df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" hex = "0.4" -tokio-util = { version = "0.7", features = ["rt"] } +tokio-util = { version = "0.7", features = ["rt", "io"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures = "0.3" rusqlite = { version = "0.37", features = ["bundled"] } diff --git a/src/core/all.rs b/src/core/all.rs index fa0795ac1..1d6b7ea2f 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -129,6 +129,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::security::all_security_registered_controllers()); // Background heartbeat loop controls controllers.extend(crate::openhuman::heartbeat::all_heartbeat_registered_controllers()); + // Ad-hoc static directory HTTP hosting for local file sharing / previews + controllers.extend(crate::openhuman::http_host::all_http_host_registered_controllers()); // Token usage and billing cost tracking controllers.extend(crate::openhuman::cost::all_cost_registered_controllers()); // Inline autocomplete settings @@ -263,6 +265,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas()); schemas.extend(crate::openhuman::security::all_security_controller_schemas()); schemas.extend(crate::openhuman::heartbeat::all_heartbeat_controller_schemas()); + schemas.extend(crate::openhuman::http_host::all_http_host_controller_schemas()); schemas.extend(crate::openhuman::cost::all_cost_controller_schemas()); schemas.extend(crate::openhuman::autocomplete::all_autocomplete_controller_schemas()); schemas diff --git a/src/openhuman/http_host/auth.rs b/src/openhuman/http_host/auth.rs new file mode 100644 index 000000000..8758acb31 --- /dev/null +++ b/src/openhuman/http_host/auth.rs @@ -0,0 +1,137 @@ +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use base64::Engine as _; +use rand::RngExt as _; + +use crate::openhuman::config; +use crate::openhuman::credentials::session_support; +use crate::openhuman::http_host::types::HostedDirAuth; +use crate::openhuman::http_host::LOG_PREFIX; + +pub(crate) fn ensure_authorized(headers: &HeaderMap, auth: &HostedDirAuth) -> Result<(), Response> { + if !auth.enabled { + return Ok(()); + } + let Some(expected_user) = auth.username.as_deref() else { + return Err(unauthorized_response()); + }; + let Some(expected_pass) = auth.password.as_deref() else { + return Err(unauthorized_response()); + }; + let Some(header_value) = headers.get(header::AUTHORIZATION) else { + return Err(unauthorized_response()); + }; + let Ok(auth_value) = header_value.to_str() else { + return Err(unauthorized_response()); + }; + let Some(encoded) = auth_value.strip_prefix("Basic ") else { + return Err(unauthorized_response()); + }; + let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) else { + return Err(unauthorized_response()); + }; + let Ok(rendered) = String::from_utf8(decoded) else { + return Err(unauthorized_response()); + }; + let Some((username, password)) = rendered.split_once(':') else { + return Err(unauthorized_response()); + }; + if username == expected_user && password == expected_pass { + Ok(()) + } else { + Err(unauthorized_response()) + } +} + +fn unauthorized_response() -> Response { + let mut response = (StatusCode::UNAUTHORIZED, "basic auth required").into_response(); + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"OpenHuman Hosted Directory\""), + ); + response +} + +pub(crate) async fn resolve_default_auth_username() -> Option { + let config = match config::load_config_with_timeout().await { + Ok(config) => config, + Err(error) => { + log::debug!("{LOG_PREFIX} default auth username config load failed: {error}"); + return fallback_env_username(); + } + }; + + match session_support::build_session_state(&config) { + Ok(state) => state + .user + .as_ref() + .and_then(resolve_default_auth_username_from_user_value) + .or(state.user_id) + .and_then(|value| sanitize_basic_auth_username(Some(value))), + Err(error) => { + log::debug!("{LOG_PREFIX} session state lookup failed for auth username: {error}"); + fallback_env_username() + } + } +} + +fn fallback_env_username() -> Option { + sanitize_basic_auth_username( + std::env::var("USER") + .ok() + .or_else(|| std::env::var("USERNAME").ok()), + ) +} + +pub(crate) fn resolve_default_auth_username_from_user_value( + user: &serde_json::Value, +) -> Option { + let object = user.as_object()?; + [ + "username", + "userName", + "handle", + "slug", + "name", + "displayName", + "display_name", + "email", + "user_id", + "userId", + "id", + ] + .iter() + .find_map(|key| object.get(*key).and_then(|value| value.as_str())) + .map(str::to_string) +} + +pub(crate) fn sanitize_basic_auth_username(value: Option) -> Option { + let raw = value?; + let mut out = String::new(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + for ch in trimmed.chars() { + match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' | '@' => out.push(ch), + ' ' => out.push('-'), + ':' => {} + _ => {} + } + if out.len() >= 64 { + break; + } + } + if out.is_empty() { + None + } else { + Some(out) + } +} + +pub(crate) fn generate_password() -> String { + let mut bytes = [0u8; 18]; + rand::rng().fill(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} diff --git a/src/openhuman/http_host/handlers.rs b/src/openhuman/http_host/handlers.rs new file mode 100644 index 000000000..d1c64e458 --- /dev/null +++ b/src/openhuman/http_host/handlers.rs @@ -0,0 +1,172 @@ +use std::path::{Path, PathBuf}; + +use axum::body::Body; +use axum::extract::{Path as AxumPath, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use tokio_util::io::ReaderStream; + +use crate::openhuman::http_host::auth::ensure_authorized; +use crate::openhuman::http_host::path_utils::{ + child_href_for, content_type_for_path, escape_html, parent_href_for, resolve_request_path, +}; +use crate::openhuman::http_host::types::HostedDirAuth; +use crate::openhuman::http_host::LOG_PREFIX; + +#[derive(Clone)] +pub(crate) struct HostedDirState { + pub(crate) root_dir: PathBuf, + pub(crate) auth: HostedDirAuth, +} + +pub(crate) fn build_router(state: HostedDirState) -> Router { + Router::new() + .route("/", get(serve_root).head(serve_root)) + .route("/{*path}", get(serve_path).head(serve_path)) + .with_state(state) +} + +async fn serve_root(State(state): State, headers: HeaderMap) -> Response { + serve_relative_path(state, headers, "").await +} + +async fn serve_path( + AxumPath(path): AxumPath, + State(state): State, + headers: HeaderMap, +) -> Response { + serve_relative_path(state, headers, &path).await +} + +async fn serve_relative_path(state: HostedDirState, headers: HeaderMap, path: &str) -> Response { + if let Err(response) = ensure_authorized(&headers, &state.auth) { + return response; + } + + let resolved = match resolve_request_path(&state.root_dir, path) { + Ok(path) => path, + Err(error) => { + log::warn!("{LOG_PREFIX} rejected path='{}': {}", path, error); + return (StatusCode::BAD_REQUEST, error).into_response(); + } + }; + + match tokio::fs::metadata(&resolved).await { + Ok(metadata) if metadata.is_dir() => { + serve_directory(&state.root_dir, &resolved, path).await + } + Ok(metadata) if metadata.is_file() => serve_file(&resolved).await, + Ok(_) => (StatusCode::NOT_FOUND, "not found").into_response(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + (StatusCode::NOT_FOUND, "not found").into_response() + } + Err(error) => { + log::warn!( + "{LOG_PREFIX} metadata failed path={} err={}", + resolved.display(), + error + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read hosted directory entry", + ) + .into_response() + } + } +} + +async fn serve_file(path: &Path) -> Response { + match tokio::fs::File::open(path).await { + Ok(file) => { + let stream = ReaderStream::new(file); + let mut response = Response::new(Body::from_stream(stream)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type_for_path(path)), + ); + response + } + Err(error) => { + log::warn!( + "{LOG_PREFIX} read file failed path={} err={}", + path.display(), + error + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read hosted file", + ) + .into_response() + } + } +} + +async fn serve_directory(root_dir: &Path, dir: &Path, requested_path: &str) -> Response { + let index_path = dir.join("index.html"); + if tokio::fs::metadata(&index_path) + .await + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + { + return serve_file(&index_path).await; + } + + match tokio::fs::read_dir(dir).await { + Ok(mut entries) => { + let mut rows = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let file_type = match entry.file_type().await { + Ok(file_type) => file_type, + Err(_) => continue, + }; + let suffix = if file_type.is_dir() { "/" } else { "" }; + rows.push((name, suffix.to_string())); + } + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + let title = if requested_path.is_empty() { + "/".to_string() + } else { + format!("/{}", requested_path.trim_matches('/')) + }; + let mut html = String::from( + "OpenHuman Directory Listing", + ); + html.push_str(&format!( + "

Directory listing for {}

    ", + escape_html(&title) + )); + if dir != root_dir { + let parent_href = parent_href_for(requested_path); + html.push_str(&format!("
  • ..
  • ", parent_href)); + } + for (name, suffix) in rows { + let href = child_href_for(requested_path, &name, suffix.as_str()); + html.push_str(&format!( + "
  • {}{}
  • ", + href, + escape_html(&name), + suffix + )); + } + html.push_str("
"); + Html(html).into_response() + } + Err(error) => { + log::warn!( + "{LOG_PREFIX} read_dir failed path={} err={}", + dir.display(), + error + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to enumerate hosted directory", + ) + .into_response() + } + } +} diff --git a/src/openhuman/http_host/mod.rs b/src/openhuman/http_host/mod.rs new file mode 100644 index 000000000..a0b0cf9b4 --- /dev/null +++ b/src/openhuman/http_host/mod.rs @@ -0,0 +1,24 @@ +//! Static directory hosting over ad-hoc HTTP listeners owned by the core. +//! +//! This domain lets trusted callers start, inspect, list, and stop lightweight +//! file servers that expose a chosen directory on a chosen TCP port. Each +//! server runs in-process, shares the core's lifetime, and defaults to HTTP +//! Basic authentication using the active user's identity plus a generated +//! password. + +mod auth; +mod handlers; +pub mod ops; +mod path_utils; +pub mod rpc; +mod schemas; +#[cfg(test)] +mod tests; +mod types; + +pub use schemas::{ + all_controller_schemas as all_http_host_controller_schemas, + all_registered_controllers as all_http_host_registered_controllers, +}; + +pub(crate) const LOG_PREFIX: &str = "[http_host]"; diff --git a/src/openhuman/http_host/ops.rs b/src/openhuman/http_host/ops.rs new file mode 100644 index 000000000..3fad8b127 --- /dev/null +++ b/src/openhuman/http_host/ops.rs @@ -0,0 +1,282 @@ +//! In-process manager for ad-hoc static directory HTTP servers. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::openhuman::http_host::auth::{ + generate_password, resolve_default_auth_username, sanitize_basic_auth_username, +}; +use crate::openhuman::http_host::handlers::{build_router, HostedDirState}; +use crate::openhuman::http_host::path_utils::{ + canonicalize_hosted_directory, redact_path_for_log, render_host_for_url, sanitize_bind_host, + sanitize_optional_label, +}; +use crate::openhuman::http_host::types::{ + HostedDirAuth, HostedDirServerInfo, StartHostedDirParams, +}; +use crate::openhuman::http_host::LOG_PREFIX; + +pub(crate) struct HostedDirRuntime { + pub(crate) info: HostedDirServerInfo, + pub(crate) shutdown: CancellationToken, + pub(crate) join_handle: JoinHandle<()>, +} + +struct HostedDirRegistry { + servers: Mutex>, +} + +impl HostedDirRegistry { + fn new() -> Self { + Self { + servers: Mutex::new(HashMap::new()), + } + } + + fn prune_finished_locked(servers: &mut HashMap) { + servers.retain(|server_id, runtime| { + let keep = !runtime.join_handle.is_finished(); + if !keep { + log::warn!("{LOG_PREFIX} pruning finished hosted server id={server_id}"); + } + keep + }); + } +} + +static REGISTRY: OnceLock = OnceLock::new(); +static SHUTDOWN_HOOK_REGISTERED: OnceLock<()> = OnceLock::new(); + +fn registry() -> &'static HostedDirRegistry { + REGISTRY.get_or_init(HostedDirRegistry::new) +} + +pub async fn start_hosted_dir_server( + params: StartHostedDirParams, +) -> Result { + register_shutdown_hook_once(); + + let root_dir = canonicalize_hosted_directory(¶ms.directory)?; + let bind_host = sanitize_bind_host(¶ms.bind_host)?; + let server_name = sanitize_optional_label(params.server_name.as_deref()); + let auth = if params.disable_auth { + HostedDirAuth { + enabled: false, + username: None, + password: None, + } + } else { + let default_username = resolve_default_auth_username().await; + let username = params + .username + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(default_username); + let username = + sanitize_basic_auth_username(username).unwrap_or_else(|| "openhuman".to_string()); + let password = generate_password(); + HostedDirAuth { + enabled: true, + username: Some(username), + password: Some(password), + } + }; + + let bind_target = if bind_host.contains(':') && !bind_host.starts_with('[') { + format!("[{bind_host}]:{}", params.port) + } else { + format!("{bind_host}:{}", params.port) + }; + log::info!( + "{LOG_PREFIX} start requested dir={} bind_target={} auth_enabled={} server_name={:?}", + redact_path_for_log(&root_dir), + bind_target, + auth.enabled, + server_name + ); + let listener = TcpListener::bind(&bind_target) + .await + .map_err(|e| format!("failed to bind hosted HTTP server on {bind_target}: {e}"))?; + let local_addr = listener + .local_addr() + .map_err(|e| format!("failed to read hosted HTTP server local addr: {e}"))?; + + let server_id = uuid::Uuid::new_v4().to_string(); + let info = HostedDirServerInfo { + server_id: server_id.clone(), + server_name, + directory: root_dir.display().to_string(), + bind_host: bind_host.clone(), + port: local_addr.port(), + base_url: format!( + "http://{}:{}/", + render_host_for_url(&bind_host), + local_addr.port() + ), + local_url: format!("http://127.0.0.1:{}/", local_addr.port()), + auth: auth.clone(), + }; + let state = HostedDirState { + root_dir: root_dir.clone(), + auth, + }; + let app = build_router(state); + + let shutdown = CancellationToken::new(); + let shutdown_signal = shutdown.clone(); + let server_id_for_task = server_id.clone(); + let join_handle = tokio::spawn(async move { + log::info!( + "{LOG_PREFIX} serving hosted directory server_id={} addr={}", + server_id_for_task, + local_addr + ); + if let Err(error) = axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_signal.cancelled().await; + }) + .await + { + log::error!( + "{LOG_PREFIX} hosted directory server_id={} exited with error: {}", + server_id_for_task, + error + ); + } else { + log::info!( + "{LOG_PREFIX} hosted directory server_id={} stopped cleanly", + server_id_for_task + ); + } + }); + + let runtime = HostedDirRuntime { + info: info.clone(), + shutdown, + join_handle, + }; + + let registry = registry(); + let mut servers = registry + .servers + .lock() + .expect("hosted-dir registry poisoned"); + HostedDirRegistry::prune_finished_locked(&mut servers); + if servers.contains_key(&server_id) { + return Err(format!("hosted HTTP server id collision: {server_id}")); + } + if servers + .values() + .any(|runtime| runtime.info.bind_host == info.bind_host && runtime.info.port == info.port) + { + return Err(format!( + "a hosted HTTP server is already registered on {}:{}", + info.bind_host, info.port + )); + } + servers.insert(server_id.clone(), runtime); + + log::info!( + "{LOG_PREFIX} started hosted directory server_id={} dir={} url={}", + server_id, + redact_path_for_log(root_dir.as_path()), + info.base_url + ); + Ok(info) +} + +pub fn list_hosted_dir_servers() -> Result, String> { + let registry = registry(); + let mut servers = registry + .servers + .lock() + .expect("hosted-dir registry poisoned"); + HostedDirRegistry::prune_finished_locked(&mut servers); + let mut out = servers + .values() + .map(|runtime| runtime.info.clone()) + .collect::>(); + out.sort_by(|a, b| a.server_id.cmp(&b.server_id)); + Ok(out) +} + +pub fn get_hosted_dir_server(server_id: &str) -> Result { + let registry = registry(); + let mut servers = registry + .servers + .lock() + .expect("hosted-dir registry poisoned"); + HostedDirRegistry::prune_finished_locked(&mut servers); + servers + .get(server_id) + .map(|runtime| runtime.info.clone()) + .ok_or_else(|| format!("hosted HTTP server not found: {server_id}")) +} + +pub async fn stop_hosted_dir_server(server_id: &str) -> Result { + let runtime = { + let registry = registry(); + let mut servers = registry + .servers + .lock() + .expect("hosted-dir registry poisoned"); + HostedDirRegistry::prune_finished_locked(&mut servers); + servers + .remove(server_id) + .ok_or_else(|| format!("hosted HTTP server not found: {server_id}"))? + }; + + log::info!( + "{LOG_PREFIX} stopping hosted directory server_id={} addr={}:{}", + runtime.info.server_id, + runtime.info.bind_host, + runtime.info.port + ); + runtime.shutdown.cancel(); + if let Err(error) = runtime.join_handle.await { + log::warn!( + "{LOG_PREFIX} hosted directory server join failed server_id={}: {}", + runtime.info.server_id, + error + ); + } + Ok(runtime.info) +} + +pub async fn stop_all_hosted_dir_servers() { + let runtimes = { + let registry = registry(); + let mut servers = registry + .servers + .lock() + .expect("hosted-dir registry poisoned"); + std::mem::take(&mut *servers) + }; + for runtime in runtimes.values() { + runtime.shutdown.cancel(); + } + for (server_id, runtime) in runtimes { + log::info!("{LOG_PREFIX} shutdown hook stopping hosted server id={server_id}"); + if let Err(error) = runtime.join_handle.await { + log::warn!( + "{LOG_PREFIX} hosted directory server join failed during shutdown server_id={}: {}", + server_id, + error + ); + } + } +} + +fn register_shutdown_hook_once() { + SHUTDOWN_HOOK_REGISTERED.get_or_init(|| { + crate::core::shutdown::register(|| async { + stop_all_hosted_dir_servers().await; + }); + }); +} diff --git a/src/openhuman/http_host/path_utils.rs b/src/openhuman/http_host/path_utils.rs new file mode 100644 index 000000000..ab93876db --- /dev/null +++ b/src/openhuman/http_host/path_utils.rs @@ -0,0 +1,166 @@ +use std::path::{Component, Path, PathBuf}; + +pub(crate) fn canonicalize_hosted_directory(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("directory must not be empty".to_string()); + } + let path = PathBuf::from(trimmed); + let canonical = std::fs::canonicalize(&path).map_err(|e| { + format!( + "failed to resolve hosted directory '{}': {e}", + path.display() + ) + })?; + let metadata = std::fs::metadata(&canonical).map_err(|e| { + format!( + "failed to read hosted directory '{}': {e}", + canonical.display() + ) + })?; + if !metadata.is_dir() { + return Err(format!( + "hosted path is not a directory: {}", + canonical.display() + )); + } + Ok(canonical) +} + +pub(crate) fn sanitize_bind_host(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("bind_host must not be empty".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') { + return Err("bind_host must be a hostname or IP address, not a path".to_string()); + } + Ok(trimmed.to_string()) +} + +pub(crate) fn sanitize_optional_label(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(120).collect()) +} + +pub(crate) fn render_host_for_url(bind_host: &str) -> String { + if bind_host.contains(':') && !bind_host.starts_with('[') { + format!("[{bind_host}]") + } else { + bind_host.to_string() + } +} + +pub(crate) fn resolve_request_path( + root_dir: &Path, + requested_path: &str, +) -> Result { + let mut candidate = root_dir.to_path_buf(); + let trimmed = requested_path.trim_matches('/'); + if trimmed.is_empty() { + return Ok(candidate); + } + + for segment in trimmed.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + let decoded = urlencoding::decode(segment) + .map_err(|e| format!("invalid URL path segment '{}': {e}", segment))?; + if decoded.contains('/') || decoded.contains('\\') || decoded.contains('\0') { + return Err(format!("invalid path segment '{}'", decoded)); + } + let path_component = Path::new(decoded.as_ref()); + if path_component.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(format!("path traversal is not allowed: '{}'", decoded)); + } + candidate.push(path_component); + } + + if candidate.exists() { + let canonical = std::fs::canonicalize(&candidate).map_err(|e| { + format!( + "failed to resolve requested path '{}': {e}", + candidate.display() + ) + })?; + if !canonical.starts_with(root_dir) { + return Err("requested path escapes hosted directory".to_string()); + } + Ok(canonical) + } else { + Ok(candidate) + } +} + +pub(crate) fn parent_href_for(requested_path: &str) -> String { + let trimmed = requested_path.trim_matches('/'); + let mut parts = trimmed + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + let _ = parts.pop(); + if parts.is_empty() { + "/".to_string() + } else { + format!("/{}/", parts.join("/")) + } +} + +pub(crate) fn child_href_for(requested_path: &str, child_name: &str, suffix: &str) -> String { + let encoded = urlencoding::encode(child_name); + let trimmed = requested_path.trim_matches('/'); + if trimmed.is_empty() { + format!("/{encoded}{suffix}") + } else { + format!("/{trimmed}/{encoded}{suffix}") + } +} + +pub(crate) fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +pub(crate) fn content_type_for_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("html") | Some("htm") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") | Some("mjs") => "application/javascript; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("txt") | Some("log") | Some("md") => "text/plain; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("pdf") => "application/pdf", + Some("csv") => "text/csv; charset=utf-8", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +pub(crate) fn redact_path_for_log(path: &Path) -> String { + let name = path + .file_name() + .and_then(|segment| segment.to_str()) + .filter(|segment| !segment.is_empty()) + .unwrap_or(""); + format!("/{name}") +} diff --git a/src/openhuman/http_host/rpc.rs b/src/openhuman/http_host/rpc.rs new file mode 100644 index 000000000..2859c099a --- /dev/null +++ b/src/openhuman/http_host/rpc.rs @@ -0,0 +1,41 @@ +//! RPC adapters for the `http_host` domain. + +use crate::openhuman::http_host::ops; +use crate::openhuman::http_host::types::{ + HostedDirGetResult, HostedDirListResult, HostedDirLookupParams, HostedDirStartResult, + HostedDirStopResult, StartHostedDirParams, +}; +use crate::rpc::RpcOutcome; + +pub async fn start( + params: StartHostedDirParams, +) -> Result, String> { + let server = ops::start_hosted_dir_server(params).await?; + Ok(RpcOutcome::single_log( + HostedDirStartResult { server }, + "started hosted directory HTTP server", + )) +} + +pub async fn stop( + params: HostedDirLookupParams, +) -> Result, String> { + let server = ops::stop_hosted_dir_server(¶ms.server_id).await?; + Ok(RpcOutcome::single_log( + HostedDirStopResult { + stopped: true, + server, + }, + "stopped hosted directory HTTP server", + )) +} + +pub async fn get(params: HostedDirLookupParams) -> Result, String> { + let server = ops::get_hosted_dir_server(¶ms.server_id)?; + Ok(RpcOutcome::new(HostedDirGetResult { server }, vec![])) +} + +pub async fn list() -> Result, String> { + let servers = ops::list_hosted_dir_servers()?; + Ok(RpcOutcome::new(HostedDirListResult { servers }, vec![])) +} diff --git a/src/openhuman/http_host/schemas.rs b/src/openhuman/http_host/schemas.rs new file mode 100644 index 000000000..05cb5fdaf --- /dev/null +++ b/src/openhuman/http_host/schemas.rs @@ -0,0 +1,239 @@ +//! Controller schemas + handlers for `http_host`. + +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::types::{HostedDirLookupParams, StartHostedDirParams}; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("start"), + schemas("stop"), + schemas("get"), + schemas("list"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("start"), + handler: handle_start, + }, + RegisteredController { + schema: schemas("stop"), + handler: handle_stop, + }, + RegisteredController { + schema: schemas("get"), + handler: handle_get, + }, + RegisteredController { + schema: schemas("list"), + handler: handle_list, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "start" => ControllerSchema { + namespace: "http_host", + function: "start", + description: "Host a local directory over HTTP on the requested port. \ + Basic auth is enabled by default using the active username plus a generated password.", + inputs: vec![ + FieldSchema { + name: "directory", + ty: TypeSchema::String, + comment: "Absolute or relative path to the directory to host.", + required: true, + }, + FieldSchema { + name: "port", + ty: TypeSchema::U64, + comment: "TCP port to bind. Use 0 to let the OS choose a free port.", + required: true, + }, + FieldSchema { + name: "bind_host", + ty: TypeSchema::String, + comment: "Interface / host to bind. Defaults to 127.0.0.1; use an explicit non-loopback host for LAN reachability.", + required: false, + }, + FieldSchema { + name: "server_name", + ty: TypeSchema::String, + comment: "Optional caller-provided label for the hosted server.", + required: false, + }, + FieldSchema { + name: "disable_auth", + ty: TypeSchema::Bool, + comment: "Disable basic auth. Defaults to false and should be used sparingly.", + required: false, + }, + FieldSchema { + name: "username", + ty: TypeSchema::String, + comment: "Optional override for the basic-auth username when auth is enabled.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "server", + ty: TypeSchema::Json, + comment: "Hosted server details including URLs and auth credentials.", + required: true, + }], + }, + "stop" => ControllerSchema { + namespace: "http_host", + function: "stop", + description: "Stop a previously started hosted-directory HTTP server.", + inputs: vec![FieldSchema { + name: "server_id", + ty: TypeSchema::String, + comment: "Opaque server id returned by http_host.start.", + required: true, + }], + outputs: vec![ + FieldSchema { + name: "stopped", + ty: TypeSchema::Bool, + comment: "Whether the server was successfully stopped.", + required: true, + }, + FieldSchema { + name: "server", + ty: TypeSchema::Json, + comment: "The stopped server's final configuration snapshot.", + required: true, + }, + ], + }, + "get" => ControllerSchema { + namespace: "http_host", + function: "get", + description: "Fetch a hosted-directory HTTP server by id.", + inputs: vec![FieldSchema { + name: "server_id", + ty: TypeSchema::String, + comment: "Opaque server id returned by http_host.start.", + required: true, + }], + outputs: vec![FieldSchema { + name: "server", + ty: TypeSchema::Json, + comment: "Hosted server details including current auth credentials.", + required: true, + }], + }, + "list" => ControllerSchema { + namespace: "http_host", + function: "list", + description: "List all currently running hosted-directory HTTP servers.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "servers", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "All active hosted servers.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "http_host", + function: "unknown", + description: "Unknown http_host controller function.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_start(params: Map) -> ControllerFuture { + Box::pin(async move { + let params: StartHostedDirParams = parse(params)?; + crate::openhuman::http_host::rpc::start(params) + .await? + .into_cli_compatible_json() + }) +} + +fn handle_stop(params: Map) -> ControllerFuture { + Box::pin(async move { + let params: HostedDirLookupParams = parse(params)?; + crate::openhuman::http_host::rpc::stop(params) + .await? + .into_cli_compatible_json() + }) +} + +fn handle_get(params: Map) -> ControllerFuture { + Box::pin(async move { + let params: HostedDirLookupParams = parse(params)?; + crate::openhuman::http_host::rpc::get(params) + .await? + .into_cli_compatible_json() + }) +} + +fn handle_list(_params: Map) -> ControllerFuture { + Box::pin(async move { + crate::openhuman::http_host::rpc::list() + .await? + .into_cli_compatible_json() + }) +} + +fn parse(params: Map) -> Result { + serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_inventory_matches_handlers() { + assert_eq!( + all_controller_schemas().len(), + all_registered_controllers().len() + ); + } + + #[test] + fn start_schema_requires_directory_and_port() { + let schema = schemas("start"); + let required: Vec<&str> = schema + .inputs + .iter() + .filter(|field| field.required) + .map(|field| field.name) + .collect(); + assert_eq!(required, vec!["directory", "port"]); + } + + #[test] + fn stop_schema_requires_server_id() { + let schema = schemas("stop"); + assert_eq!(schema.inputs.len(), 1); + assert_eq!(schema.inputs[0].name, "server_id"); + assert!(schema.inputs[0].required); + } + + #[test] + fn unknown_schema_falls_back() { + let schema = schemas("nope"); + assert_eq!(schema.namespace, "http_host"); + assert_eq!(schema.function, "unknown"); + } +} diff --git a/src/openhuman/http_host/tests.rs b/src/openhuman/http_host/tests.rs new file mode 100644 index 000000000..04923e7c6 --- /dev/null +++ b/src/openhuman/http_host/tests.rs @@ -0,0 +1,95 @@ +use std::io::Write as _; +use std::sync::Mutex; + +use axum::http::StatusCode; + +use crate::openhuman::http_host::auth::{ + resolve_default_auth_username_from_user_value, sanitize_basic_auth_username, +}; +use crate::openhuman::http_host::ops::{ + list_hosted_dir_servers, start_hosted_dir_server, stop_all_hosted_dir_servers, + stop_hosted_dir_server, +}; +use crate::openhuman::http_host::path_utils::resolve_request_path; +use crate::openhuman::http_host::types::StartHostedDirParams; + +static TEST_MUTEX: Mutex<()> = Mutex::new(()); + +#[test] +fn sanitize_basic_auth_username_normalizes_and_limits() { + let username = sanitize_basic_auth_username(Some(" Jane Doe:admin ".to_string())).unwrap(); + assert_eq!(username, "Jane-Doeadmin"); +} + +#[test] +fn resolve_user_name_prefers_username_like_fields() { + let user = serde_json::json!({ + "displayName": "Display Name", + "username": "primary-user" + }); + assert_eq!( + resolve_default_auth_username_from_user_value(&user).as_deref(), + Some("primary-user") + ); +} + +#[test] +fn resolve_request_path_rejects_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let err = resolve_request_path(&root, "../secret").unwrap_err(); + assert!(err.contains("path traversal")); +} + +#[tokio::test] +async fn start_serves_files_with_basic_auth() { + let _guard = TEST_MUTEX.lock().unwrap(); + stop_all_hosted_dir_servers().await; + + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("hello.txt"); + let mut file = std::fs::File::create(&file_path).unwrap(); + writeln!(file, "hello from hosted dir").unwrap(); + + let server = start_hosted_dir_server(StartHostedDirParams { + directory: tmp.path().display().to_string(), + port: 0, + bind_host: "127.0.0.1".to_string(), + server_name: Some("test".to_string()), + disable_auth: false, + username: Some("tester".to_string()), + }) + .await + .unwrap(); + + let client = reqwest::Client::builder().build().unwrap(); + let unauthorized = client + .get(format!("{}hello.txt", server.local_url)) + .send() + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let auth_user = server.auth.username.clone().unwrap(); + let auth_pass = server.auth.password.clone().unwrap(); + let authorized = client + .get(format!("{}hello.txt", server.local_url)) + .basic_auth(auth_user, Some(auth_pass)) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + assert!(authorized + .text() + .await + .unwrap() + .contains("hello from hosted dir")); + + let listed = list_hosted_dir_servers().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].server_id, server.server_id); + + let stopped = stop_hosted_dir_server(&server.server_id).await.unwrap(); + assert_eq!(stopped.server_id, server.server_id); + assert!(list_hosted_dir_servers().unwrap().is_empty()); +} diff --git a/src/openhuman/http_host/types.rs b/src/openhuman/http_host/types.rs new file mode 100644 index 000000000..26dc77eb4 --- /dev/null +++ b/src/openhuman/http_host/types.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct HostedDirAuth { + pub enabled: bool, + pub username: Option, + pub password: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HostedDirServerInfo { + pub server_id: String, + pub server_name: Option, + pub directory: String, + pub bind_host: String, + pub port: u16, + pub base_url: String, + pub local_url: String, + pub auth: HostedDirAuth, +} + +#[derive(Debug, Deserialize)] +pub struct StartHostedDirParams { + pub directory: String, + pub port: u16, + #[serde(default = "default_bind_host")] + pub bind_host: String, + #[serde(default)] + pub server_name: Option, + #[serde(default)] + pub disable_auth: bool, + #[serde(default)] + pub username: Option, +} + +fn default_bind_host() -> String { + "127.0.0.1".to_string() +} + +#[derive(Debug, Deserialize)] +pub struct HostedDirLookupParams { + pub server_id: String, +} + +#[derive(Debug, Serialize)] +pub struct HostedDirListResult { + pub servers: Vec, +} + +#[derive(Debug, Serialize)] +pub struct HostedDirGetResult { + pub server: HostedDirServerInfo, +} + +#[derive(Debug, Serialize)] +pub struct HostedDirStartResult { + pub server: HostedDirServerInfo, +} + +#[derive(Debug, Serialize)] +pub struct HostedDirStopResult { + pub stopped: bool, + pub server: HostedDirServerInfo, +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index fd21c64b5..5b9198dd7 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -36,6 +36,7 @@ pub mod embeddings; pub mod encryption; pub mod health; pub mod heartbeat; +pub mod http_host; pub mod integrations; pub mod learning; pub mod local_ai;