mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
feat(core): add authenticated static directory hosting (#1966)
This commit is contained in:
+1
-1
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]";
|
||||
@@ -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(¶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<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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.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("<root>");
|
||||
format!("<redacted>/{name}")
|
||||
}
|
||||
@@ -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(¶ms.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(¶ms.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![]))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user