pub mod browse;
pub mod inspect;
pub mod range;
pub mod router;
use crate::common::sys::lifecycle::Error;
use crate::engine::interfaces::{
HttpMiddleware, L7Middleware, MiddlewareOutput, ParamDef, ParamType, Plugin, ResolvedInputs,
};
use crate::layers::l7::{
container::{Container, PayloadState},
http::wrapper::VaneBody,
};
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use futures_util::StreamExt;
use http::HeaderValue;
use http_body::Frame;
use http_body_util::{BodyExt, StreamBody};
use serde_json::Value;
use std::{any::Any, borrow::Cow};
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
use tokio_util::io::ReaderStream;
pub struct StaticPlugin;
impl Plugin for StaticPlugin {
fn name(&self) -> &'static str {
"internal.driver.static"
}
fn params(&self) -> Vec<ParamDef> {
vec![
ParamDef {
name: Cow::Borrowed("root"),
required: true,
param_type: ParamType::String,
},
ParamDef {
name: Cow::Borrowed("uri"),
required: true,
param_type: ParamType::String,
},
ParamDef {
name: Cow::Borrowed("index"),
required: false,
param_type: ParamType::String,
},
ParamDef {
name: Cow::Borrowed("spa"),
required: false,
param_type: ParamType::Boolean,
},
ParamDef {
name: Cow::Borrowed("browse"),
required: false,
param_type: ParamType::Boolean,
},
ParamDef {
name: Cow::Borrowed("precompress"),
required: false,
param_type: ParamType::Boolean,
},
ParamDef {
name: Cow::Borrowed("symlink"),
required: false,
param_type: ParamType::Boolean,
},
]
}
fn supported_protocols(&self) -> Vec<Cow<'static, str>> {
vec!["httpx".into()]
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_http_middleware(&self) -> Option<&dyn HttpMiddleware> {
Some(self)
}
fn as_l7_middleware(&self) -> Option<&dyn L7Middleware> {
Some(self)
}
}
#[async_trait]
impl HttpMiddleware for StaticPlugin {
fn output(&self) -> Vec<Cow<'static, str>> {
vec![
Cow::Borrowed("success"),
Cow::Borrowed("not_found"),
Cow::Borrowed("failure"),
]
}
async fn execute(
&self,
context: &mut (dyn Any + Send),
inputs: ResolvedInputs,
) -> Result<MiddlewareOutput> {
let container = context
.downcast_mut::<Container>()
.ok_or_else(|| anyhow::anyhow!("Context is not a Container"))?;
let root = inputs
.get("root")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing root"))?;
let request_path = inputs
.get("uri")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing uri"))?;
let uri_path = request_path.split('?').next().unwrap_or("/");
let index_file = inputs
.get("index")
.and_then(Value::as_str)
.unwrap_or("index.html");
let spa_mode = inputs.get("spa").and_then(Value::as_bool).unwrap_or(false);
let browse_enabled = inputs
.get("browse")
.and_then(Value::as_bool)
.unwrap_or(false);
let precompress = inputs
.get("precompress")
.and_then(Value::as_bool)
.unwrap_or(false);
let allow_symlinks = inputs
.get("symlink")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut fs_path = match router::resolve_path(root, uri_path, allow_symlinks) {
Ok(p) => p,
Err(e) => {
container.kv.insert("res.error".to_owned(), e.to_string());
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("failure"),
store: None,
});
}
};
let mut metadata = match tokio::fs::metadata(&fs_path).await {
Ok(m) => m,
Err(_) => {
if spa_mode {
if let Ok(root_path) = router::resolve_path(root, "/", allow_symlinks) {
let fallback = root_path.join(index_file);
if let Ok(m) = tokio::fs::metadata(&fallback).await {
fs_path = fallback;
m
} else {
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("not_found"),
store: None,
});
}
} else {
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("not_found"),
store: None,
});
}
} else {
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("not_found"),
store: None,
});
}
}
};
if metadata.is_dir() {
let index_path = fs_path.join(index_file);
if let Ok(m) = tokio::fs::metadata(&index_path).await {
fs_path = index_path;
metadata = m;
} else if browse_enabled {
let html = browse::generate_listing(&fs_path, uri_path).await?;
container.response_headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
container.response_body = PayloadState::new_buffered(html)?;
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("success"),
store: None,
});
} else {
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("not_found"),
store: None,
});
}
}
let mut file = File::open(&fs_path).await.context("Failed to open file")?;
let content_type = inspect::determine_mime_type(&fs_path, &mut file).await;
let mut content_encoding = None;
let range_header = container
.request_headers
.get(http::header::RANGE)
.and_then(|h| h.to_str().ok());
let mut range = None;
if let Some(rh) = range_header {
range = range::parse_range_header(rh, metadata.len());
}
if range.is_none() && precompress {
let accept_encoding = container
.request_headers
.get(http::header::ACCEPT_ENCODING)
.and_then(|h| h.to_str().ok())
.unwrap_or("");
if accept_encoding.contains("gzip") {
let gz_path = fs_path.with_extension(format!(
"{}.gz",
fs_path.extension().unwrap_or_default().to_string_lossy()
));
if let Ok(gz_meta) = tokio::fs::metadata(&gz_path).await
&& let Ok(gz_file) = File::open(&gz_path).await
{
file = gz_file;
metadata = gz_meta;
content_encoding = Some("gzip");
}
}
}
let headers = &mut container.response_headers;
let ct_val = HeaderValue::from_str(&content_type)
.map_err(|e| anyhow!("Invalid content-type generated: {e}"))?;
headers.insert(http::header::CONTENT_TYPE, ct_val);
if let Some(enc) = content_encoding {
headers.insert(
http::header::CONTENT_ENCODING,
HeaderValue::from_static(enc),
);
}
let last_modified = inspect::generate_etag(
metadata.modified().unwrap_or(std::time::SystemTime::now()),
metadata.len(),
);
if let Ok(val) = HeaderValue::from_str(&last_modified) {
headers.insert(http::header::ETAG, val);
}
let mut length = metadata.len();
if let Some(r) = range {
if (file.seek(SeekFrom::Start(r.start)).await).is_err() {
container
.kv
.insert("res.status".to_owned(), "500".to_owned());
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("failure"),
store: None,
});
}
length = r.length;
container
.kv
.insert("res.status".to_owned(), "206".to_owned());
let range_val = format!(
"bytes {}-{}/{}",
r.start,
r.start + r.length - 1,
metadata.len()
);
let cr_val = HeaderValue::from_str(&range_val)
.map_err(|e| anyhow!("Invalid content-range generated: {e}"))?;
headers.insert(http::header::CONTENT_RANGE, cr_val);
} else if range_header.is_some() && range.is_none() {
container
.kv
.insert("res.status".to_owned(), "416".to_owned());
let range_val = format!("bytes */{}", metadata.len());
let cr_val = HeaderValue::from_str(&range_val)
.map_err(|e| anyhow!("Invalid content-range generated: {e}"))?;
headers.insert(http::header::CONTENT_RANGE, cr_val);
container.response_body = PayloadState::Empty;
return Ok(MiddlewareOutput {
branch: Cow::Borrowed("success"),
store: None,
});
} else {
headers.insert(
http::header::ACCEPT_RANGES,
HeaderValue::from_static("bytes"),
);
}
headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from(length));
let stream = ReaderStream::new(file.take(length));
let body_stream = stream.map(|result: std::io::Result<bytes::Bytes>| match result {
Ok(bytes) => Ok(Frame::data(bytes)),
Err(e) => Err(Error::Io(e.to_string())),
});
let boxed_body = BodyExt::boxed(StreamBody::new(body_stream));
container.response_body = PayloadState::Http(VaneBody::Generic(boxed_body));
Ok(MiddlewareOutput {
branch: Cow::Borrowed("success"),
store: None,
})
}
}
#[async_trait]
impl L7Middleware for StaticPlugin {
fn output(&self) -> Vec<Cow<'static, str>> {
<Self as HttpMiddleware>::output(self)
}
async fn execute_l7(
&self,
context: &mut (dyn Any + Send),
inputs: ResolvedInputs,
) -> Result<MiddlewareOutput> {
<Self as HttpMiddleware>::execute(self, context, inputs).await
}
}