feat(core): add authenticated static directory hosting (#1966)

This commit is contained in:
Steven Enamakel
2026-05-16 16:43:58 -07:00
committed by GitHub
parent d92cebdbdf
commit fe8237af88
12 changed files with 1225 additions and 1 deletions
+1 -1
View File
@@ -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"] }
+3
View File
@@ -129,6 +129,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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
+137
View File
@@ -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<String> {
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<String> {
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<String> {
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<String>) -> Option<String> {
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)
}
+172
View File
@@ -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<HostedDirState>, headers: HeaderMap) -> Response {
serve_relative_path(state, headers, "").await
}
async fn serve_path(
AxumPath(path): AxumPath<String>,
State(state): State<HostedDirState>,
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(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OpenHuman Directory Listing</title></head><body>",
);
html.push_str(&format!(
"<h1>Directory listing for {}</h1><ul>",
escape_html(&title)
));
if dir != root_dir {
let parent_href = parent_href_for(requested_path);
html.push_str(&format!("<li><a href=\"{}\">..</a></li>", parent_href));
}
for (name, suffix) in rows {
let href = child_href_for(requested_path, &name, suffix.as_str());
html.push_str(&format!(
"<li><a href=\"{}\">{}{}</a></li>",
href,
escape_html(&name),
suffix
));
}
html.push_str("</ul></body></html>");
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()
}
}
}
+24
View File
@@ -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]";
+282
View File
@@ -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<HashMap<String, HostedDirRuntime>>,
}
impl HostedDirRegistry {
fn new() -> Self {
Self {
servers: Mutex::new(HashMap::new()),
}
}
fn prune_finished_locked(servers: &mut HashMap<String, HostedDirRuntime>) {
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<HostedDirRegistry> = 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<HostedDirServerInfo, String> {
register_shutdown_hook_once();
let root_dir = canonicalize_hosted_directory(&params.directory)?;
let bind_host = sanitize_bind_host(&params.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<Vec<HostedDirServerInfo>, 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::<Vec<_>>();
out.sort_by(|a, b| a.server_id.cmp(&b.server_id));
Ok(out)
}
pub fn get_hosted_dir_server(server_id: &str) -> Result<HostedDirServerInfo, String> {
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<HostedDirServerInfo, String> {
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;
});
});
}
+166
View File
@@ -0,0 +1,166 @@
use std::path::{Component, Path, PathBuf};
pub(crate) fn canonicalize_hosted_directory(input: &str) -> Result<PathBuf, String> {
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<String, String> {
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<String> {
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<PathBuf, String> {
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::<Vec<_>>();
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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("<root>");
format!("<redacted>/{name}")
}
+41
View File
@@ -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<RpcOutcome<HostedDirStartResult>, 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<RpcOutcome<HostedDirStopResult>, String> {
let server = ops::stop_hosted_dir_server(&params.server_id).await?;
Ok(RpcOutcome::single_log(
HostedDirStopResult {
stopped: true,
server,
},
"stopped hosted directory HTTP server",
))
}
pub async fn get(params: HostedDirLookupParams) -> Result<RpcOutcome<HostedDirGetResult>, String> {
let server = ops::get_hosted_dir_server(&params.server_id)?;
Ok(RpcOutcome::new(HostedDirGetResult { server }, vec![]))
}
pub async fn list() -> Result<RpcOutcome<HostedDirListResult>, String> {
let servers = ops::list_hosted_dir_servers()?;
Ok(RpcOutcome::new(HostedDirListResult { servers }, vec![]))
}
+239
View File
@@ -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<ControllerSchema> {
vec![
schemas("start"),
schemas("stop"),
schemas("get"),
schemas("list"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
crate::openhuman::http_host::rpc::list()
.await?
.into_cli_compatible_json()
})
}
fn parse<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
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");
}
}
+95
View File
@@ -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());
}
+64
View File
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct HostedDirAuth {
pub enabled: bool,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HostedDirServerInfo {
pub server_id: String,
pub server_name: Option<String>,
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<String>,
#[serde(default)]
pub disable_auth: bool,
#[serde(default)]
pub username: Option<String>,
}
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<HostedDirServerInfo>,
}
#[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,
}
+1
View File
@@ -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;