use std::convert::{AsRef, Infallible};
use std::io;
use std::path::{Path, PathBuf};
use std::str::Utf8Error;
use std::sync::Arc;
use std::time::Duration;
use chrono::Local;
use futures::TryStreamExt as _;
use headers::{
AcceptRanges, AccessControlAllowHeaders, AccessControlAllowOrigin, CacheControl, ContentLength,
ContentType, ETag, HeaderMapExt, LastModified, Range, Server,
};
use hyper::header::{HeaderValue, CONTENT_DISPOSITION};
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, StatusCode};
use ignore::gitignore::Gitignore;
use mime_guess::mime;
use percent_encoding::percent_decode;
use qstring::QString;
use serde::Serialize;
use crate::cli::Args;
use crate::extensions::{MimeExt, PathExt, SystemTimeExt};
use crate::http::conditional_requests::{is_fresh, is_precondition_failed};
use crate::http::content_encoding::{compress_stream, get_prior_encoding, should_compress};
use crate::http::range_requests::{is_range_fresh, is_satisfiable_range};
use crate::server::send::{send_dir, send_dir_as_zip, send_file, send_file_with_range};
use crate::server::{res, Request, Response};
use crate::BoxResult;
const SERVER_VERSION: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
const CROSS_ORIGIN_EMBEDDER_POLICY: &str = "Cross-Origin-Embedder-Policy";
const CROSS_ORIGIN_OPENER_POLICY: &str = "Cross-Origin-Opener-Policy";
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
pub enum PathType {
Dir,
SymlinkDir,
File,
SymlinkFile,
}
pub async fn serve(args: Args) -> BoxResult<()> {
let address = args.address()?;
let path_prefix = args.path_prefix.clone().unwrap_or_default();
let inner = Arc::new(InnerService::new(args));
let make_svc = make_service_fn(move |_| {
let inner = inner.clone();
async {
Ok::<_, Infallible>(service_fn(move |req| {
let inner = inner.clone();
inner.call(req)
}))
}
});
let server = hyper::Server::try_bind(&address)?.serve(make_svc);
let address = server.local_addr();
eprintln!("Files served on http://{address}{path_prefix}");
server.await?;
Ok(())
}
enum Action {
DownloadZip,
ListDir,
DownloadFile,
}
struct InnerService {
args: Args,
gitignore: Gitignore,
}
impl InnerService {
pub fn new(args: Args) -> Self {
let gitignore = Gitignore::new(args.path.join(".gitignore")).0;
Self { args, gitignore }
}
pub async fn call(self: Arc<Self>, req: Request) -> Result<Response, hyper::Error> {
let res = self
.handle_request(&req)
.await
.unwrap_or_else(|_| res::internal_server_error(Response::default()));
if self.args.log {
println!(
r#"[{}] "{} {}" - {}"#,
Local::now().format("%d/%b/%Y %H:%M:%S"),
req.method(),
req.uri(),
res.status(),
);
}
Ok(res)
}
fn file_path_from_path(&self, path: &str) -> Result<Option<PathBuf>, Utf8Error> {
let decoded = percent_decode(path[1..].as_bytes()).decode_utf8()?;
let slashes_switched = if cfg!(windows) {
decoded.replace("/", "\\")
} else {
decoded.into_owned()
};
let stripped_path = match self.strip_path_prefix(&slashes_switched) {
Some(path) => path,
None => return Ok(None),
};
let mut path = self.args.path.join(stripped_path);
if self.args.render_index && path.is_dir() {
path.push("index.html")
}
Ok(Some(path))
}
fn enable_cache_control(&self, res: &mut Response) {
let header = CacheControl::new()
.with_public()
.with_max_age(Duration::from_secs(self.args.cache));
res.headers_mut().typed_insert(header);
}
fn enable_cors(&self, res: &mut Response) {
if self.args.cors {
res.headers_mut()
.typed_insert(AccessControlAllowOrigin::ANY);
res.headers_mut().typed_insert(
vec![
hyper::header::RANGE,
hyper::header::CONTENT_TYPE,
hyper::header::ACCEPT,
hyper::header::ORIGIN,
]
.into_iter()
.collect::<AccessControlAllowHeaders>(),
);
}
}
fn enable_coi(&self, res: &mut Response) {
if self.args.coi {
res.headers_mut().insert(
CROSS_ORIGIN_EMBEDDER_POLICY,
HeaderValue::from_str("require-corp").unwrap(),
);
res.headers_mut().insert(
CROSS_ORIGIN_OPENER_POLICY,
HeaderValue::from_str("same-origin").unwrap(),
);
}
}
fn can_compress(&self, status: StatusCode, mime: &mime::Mime) -> bool {
self.args.compress && status != StatusCode::PARTIAL_CONTENT && !mime.is_compressed_format()
}
fn path_exists<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref();
path.exists() && !self.path_is_hidden(path) && !self.path_is_ignored(path)
}
fn path_is_hidden<P: AsRef<Path>>(&self, path: P) -> bool {
!self.args.all && path.as_ref().is_relatively_hidden()
}
fn path_is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref();
self.args.ignore && self.gitignore.matched(path, path.is_dir()).is_ignore()
}
fn path_is_under_basepath<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref();
match path.canonicalize() {
Ok(path) => path.starts_with(&self.args.path),
Err(_) => false,
}
}
fn strip_path_prefix<'a, P: AsRef<Path>>(&self, path: &'a P) -> Option<&'a Path> {
let path = path.as_ref();
match self.args.path_prefix.as_deref() {
Some(prefix) => {
let prefix = prefix.trim_start_matches('/');
path.strip_prefix(prefix).ok()
}
None => Some(path),
}
}
async fn handle_request(&self, req: &Request) -> BoxResult<Response> {
let mut res = Response::default();
res.headers_mut()
.typed_insert(Server::from_static(SERVER_VERSION));
let path = match self.file_path_from_path(req.uri().path())? {
Some(path) => path,
None => return Ok(res::not_found(res)),
};
let default_action = if path.is_dir() {
Action::ListDir
} else {
Action::DownloadFile
};
let action = match req.uri().query() {
Some(query) => {
let query = QString::from(query);
match query.get("action") {
Some(action_str) => match action_str {
"zip" => {
if path.is_dir() {
Action::DownloadZip
} else {
bail!("error: invalid action");
}
}
_ => bail!("error: invalid action"),
},
None => default_action,
}
}
None => default_action,
};
self.enable_cors(&mut res);
self.enable_coi(&mut res);
if !self.path_exists(&path) {
return Ok(res::not_found(res));
}
if !self.args.follow_links && !self.path_is_under_basepath(&path) {
return Ok(res::forbidden(res));
}
let mut body = Body::empty();
let mut content_length = None;
match action {
Action::ListDir => {
let (content, size) = send_dir(
&path,
&self.args.path,
self.args.all,
self.args.ignore,
self.args.path_prefix.as_deref(),
)?;
body = Body::from(content);
content_length = Some(size as u64);
}
Action::DownloadFile => {
self.enable_cache_control(&mut res);
let (mtime, size) = (path.mtime(), path.size());
let last_modified = LastModified::from(mtime);
let etag = format!(r#""{}-{}""#, mtime.timestamp(), size)
.parse::<ETag>()
.unwrap();
if is_precondition_failed(req, &etag, mtime) {
return Ok(res::precondition_failed(res));
}
if is_fresh(req, &etag, mtime) {
res.headers_mut().typed_insert(last_modified);
res.headers_mut().typed_insert(etag);
return Ok(res::not_modified(res));
}
if let Some(range) = req.headers().typed_get::<Range>() {
#[allow(clippy::single_match)]
match (
is_range_fresh(req, &etag, &last_modified),
is_satisfiable_range(&range, size as u64),
) {
(true, Some(content_range)) => {
if let Some(range) = content_range.bytes_range() {
let (stream, size) = send_file_with_range(&path, range)?;
body = Body::wrap_stream(stream);
content_length = Some(size);
}
res.headers_mut().typed_insert(content_range);
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
}
_ => (),
}
}
if res.status() != StatusCode::PARTIAL_CONTENT {
let (stream, size) = send_file(&path)?;
body = Body::wrap_stream(stream);
content_length = Some(size);
}
res.headers_mut().typed_insert(last_modified);
res.headers_mut().typed_insert(etag);
}
Action::DownloadZip => {
let (stream, size) = send_dir_as_zip(&path, self.args.all, self.args.ignore)?;
body = Body::wrap_stream(stream);
content_length = Some(size);
res.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}.zip\"",
path.file_name().unwrap().to_str().unwrap()
))
.unwrap(),
);
}
}
let mime_type = InnerService::guess_path_mime(&path, action);
let body = if self.can_compress(res.status(), &mime_type) {
if let Some(encoding) = req.headers().get(hyper::header::ACCEPT_ENCODING) {
let content_encoding = get_prior_encoding(encoding);
if should_compress(content_encoding) {
let b = compress_stream(
body.map_err(|e| io::Error::new(io::ErrorKind::Other, e)),
content_encoding,
)?;
res.headers_mut().insert(
hyper::header::CONTENT_ENCODING,
hyper::header::HeaderValue::from_static(content_encoding),
);
res.headers_mut().insert(
hyper::header::VARY,
hyper::header::HeaderValue::from_name(hyper::header::ACCEPT_ENCODING),
);
b
} else {
body
}
} else {
body
}
} else {
if let Some(content_length) = content_length {
res.headers_mut()
.typed_insert(ContentLength(content_length));
}
body
};
res.headers_mut().typed_insert(AcceptRanges::bytes());
res.headers_mut().typed_insert(ContentType::from(mime_type));
*res.body_mut() = body;
Ok(res)
}
fn guess_path_mime<P: AsRef<Path>>(path: P, action: Action) -> mime::Mime {
let path = path.as_ref();
path.mime()
.map(|x| match x.get_param(mime::CHARSET) {
Some(_) => x,
None => x
.guess_charset()
.and_then(|c| format!("{}; charset={}", x, c).parse().ok())
.unwrap_or(x),
})
.unwrap_or_else(|| match action {
Action::ListDir => mime::TEXT_HTML_UTF_8,
Action::DownloadFile => mime::TEXT_PLAIN_UTF_8,
Action::DownloadZip => mime::APPLICATION_OCTET_STREAM,
})
}
}
#[cfg(test)]
mod t_server {
use super::*;
use crate::test_utils::{get_tests_dir, with_current_dir};
use std::fs::File;
use tempfile::Builder;
fn bootstrap(args: Args) -> (InnerService, Response) {
(InnerService::new(args), Response::default())
}
const fn temp_name() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "-", env!("CARGO_PKG_VERSION"))
}
#[test]
fn file_path_from_path() {
let args = Args {
render_index: false,
path: Path::new("/storage").to_owned(),
..Default::default()
};
let (service, _) = bootstrap(args);
let path = "/%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C";
assert_eq!(
service.file_path_from_path(path).unwrap(),
Some(PathBuf::from("/storage/ä½ å¥½ä¸–ç•Œ"))
);
let dir = Builder::new().prefix(temp_name()).tempdir().unwrap();
let args = Args {
path: dir.path().to_owned(),
..Default::default()
};
let (service, _) = bootstrap(args);
assert_eq!(
service.file_path_from_path(".").unwrap(),
Some(dir.path().join("index.html")),
);
}
#[test]
fn guess_path_mime() {
let mime_type =
InnerService::guess_path_mime("file-wthout-extension", Action::DownloadFile);
assert_eq!(mime_type, mime::TEXT_PLAIN_UTF_8);
let mime_type = InnerService::guess_path_mime("file.json", Action::DownloadFile);
let json_utf8 = "application/json; charset=utf-8"
.parse::<mime::Mime>()
.unwrap();
assert_eq!(mime_type, json_utf8);
assert_eq!(mime_type.get_param(mime::CHARSET), Some(mime::UTF_8));
let mime_type = InnerService::guess_path_mime("lib.wasm", Action::DownloadFile);
let wasm = "application/wasm".parse::<mime::Mime>().unwrap();
assert_eq!(mime_type, wasm);
assert_eq!(mime_type.get_param(mime::CHARSET), None);
let dir_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mime_type = InnerService::guess_path_mime(dir_path, Action::ListDir);
assert_eq!(mime_type, mime::TEXT_HTML_UTF_8);
let dir_path = PathBuf::from("./tests");
let mime_type = InnerService::guess_path_mime(dir_path, Action::DownloadZip);
assert_eq!(mime_type, mime::APPLICATION_OCTET_STREAM);
}
#[test]
fn enable_cors() {
let args = Args::default();
let (service, mut res) = bootstrap(args);
service.enable_cors(&mut res);
assert_eq!(
res.headers()
.typed_get::<AccessControlAllowOrigin>()
.unwrap(),
AccessControlAllowOrigin::ANY,
);
assert_eq!(
res.headers()
.typed_get::<AccessControlAllowHeaders>()
.unwrap(),
vec![
hyper::header::RANGE,
hyper::header::CONTENT_TYPE,
hyper::header::ACCEPT,
hyper::header::ORIGIN,
]
.into_iter()
.collect::<AccessControlAllowHeaders>(),
);
}
#[test]
fn enable_coi() {
let args = Args::default();
let (service, mut res) = bootstrap(args);
service.enable_coi(&mut res);
assert_eq!(
res.headers().get(CROSS_ORIGIN_OPENER_POLICY).unwrap(),
"same-origin",
);
assert_eq!(
res.headers().get(CROSS_ORIGIN_EMBEDDER_POLICY).unwrap(),
"require-corp",
);
}
#[test]
fn disable_cors() {
let args = Args {
cors: false,
..Default::default()
};
let (service, mut res) = bootstrap(args);
service.enable_cors(&mut res);
assert!(res
.headers()
.typed_get::<AccessControlAllowOrigin>()
.is_none());
}
#[test]
fn enable_cache_control() {
let args = Args::default();
let (service, mut res) = bootstrap(args);
service.enable_cache_control(&mut res);
assert_eq!(
res.headers().typed_get::<CacheControl>().unwrap(),
CacheControl::new()
.with_public()
.with_max_age(Duration::default()),
);
let cache = 3600;
let args = Args {
cache,
..Default::default()
};
let (service, mut res) = bootstrap(args);
service.enable_cache_control(&mut res);
assert_eq!(
res.headers().typed_get::<CacheControl>().unwrap(),
CacheControl::new()
.with_public()
.with_max_age(Duration::from_secs(3600)),
);
}
#[test]
fn can_compress() {
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(service.can_compress(StatusCode::OK, &mime::TEXT_PLAIN));
}
#[test]
fn cannot_compress() {
let args = Args {
compress: false,
..Default::default()
};
let (service, _) = bootstrap(args);
assert!(!service.can_compress(StatusCode::OK, &mime::STAR_STAR));
assert!(!service.can_compress(StatusCode::OK, &mime::TEXT_PLAIN));
assert!(!service.can_compress(StatusCode::OK, &mime::IMAGE_JPEG));
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(!service.can_compress(StatusCode::PARTIAL_CONTENT, &mime::STAR_STAR));
assert!(!service.can_compress(StatusCode::PARTIAL_CONTENT, &mime::TEXT_PLAIN));
assert!(!service.can_compress(StatusCode::PARTIAL_CONTENT, &mime::IMAGE_JPEG));
assert!(!service.can_compress(StatusCode::OK, &"video/*".parse::<mime::Mime>().unwrap()));
assert!(!service.can_compress(StatusCode::OK, &"audio/*".parse::<mime::Mime>().unwrap()));
}
#[test]
fn path_exists() {
with_current_dir(get_tests_dir(), || {
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(service.path_exists("file.txt"));
});
}
#[test]
fn path_does_not_exists() {
with_current_dir(get_tests_dir(), || {
let args = Args {
all: false,
..Default::default()
};
let (service, _) = bootstrap(args);
let path = "NOT_EXISTS_README.md";
assert!(!PathBuf::from(path).exists());
assert!(!service.path_exists(path));
let path = ".hidden.html";
assert!(PathBuf::from(path).exists());
assert!(service.path_is_hidden(path));
assert!(!service.path_exists(path));
let path = ".hidden/nested.html";
assert!(PathBuf::from(path).exists());
assert!(service.path_is_hidden(path));
assert!(!service.path_exists(path));
let path = "ignore_pattern";
assert!(PathBuf::from(path).exists());
assert!(!service.path_is_hidden(path));
assert!(!service.path_exists(path));
});
}
#[test]
fn path_is_hidden() {
let args = Args {
all: false,
..Default::default()
};
let (service, _) = bootstrap(args);
assert!(service.path_is_hidden(".a-hidden-file"));
}
#[test]
fn path_is_not_hidden() {
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(!service.path_is_hidden(".a-hidden-file"));
assert!(!service.path_is_hidden("a-public-file"));
let args = Args {
all: false,
..Default::default()
};
let (service, _) = bootstrap(args);
assert!(!service.path_is_hidden("a-public-file"));
}
#[test]
fn path_is_ignored() {
with_current_dir(get_tests_dir(), || {
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(service.path_is_ignored("ignore_pattern"));
assert!(service.path_is_ignored("dir/ignore_pattern"));
});
}
#[test]
fn path_is_not_ignored() {
with_current_dir(get_tests_dir(), || {
let args = Args {
ignore: false,
..Default::default()
};
let (service, _) = bootstrap(args);
assert!(!service.path_is_ignored("ignore_pattern"));
assert!(!service.path_is_ignored("dir/ignore_pattern"));
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(!service.path_is_ignored("file.txt"));
assert!(!service.path_is_ignored(".hidden.html"));
});
}
#[test]
fn path_is_under_basepath() {
#[cfg(unix)]
use std::os::unix::fs::symlink as symlink_file;
#[cfg(windows)]
use std::os::windows::fs::symlink_file;
let src_dir = Builder::new().prefix(temp_name()).tempdir().unwrap();
let src_dir = src_dir.path().canonicalize().unwrap();
let src_path = src_dir.join("src_file.txt");
let _ = File::create(&src_path);
let symlink_path = src_dir.join("symlink");
let args = Args {
path: src_dir,
..Default::default()
};
let (service, _) = bootstrap(args);
symlink_file(&src_path, &symlink_path).unwrap();
assert!(service.path_is_under_basepath(&symlink_path));
let args = Args::default();
let (service, _) = bootstrap(args);
assert!(!service.path_is_under_basepath(&symlink_path));
}
#[test]
fn strips_path_prefix() {
let args = Args {
path_prefix: Some("/foo".into()),
..Default::default()
};
let (service, _) = bootstrap(args);
assert_eq!(
service.strip_path_prefix(&Path::new("foo/dir/to/bar.txt")),
Some(Path::new("dir/to/bar.txt"))
);
assert_eq!(
service.strip_path_prefix(&Path::new("dir/to/bar.txt")),
None
);
let args = Args::default();
let (service, _) = bootstrap(args);
assert_eq!(
service.strip_path_prefix(&Path::new("foo/dir/to/bar.txt")),
Some(Path::new("foo/dir/to/bar.txt"))
);
}
#[ignore]
#[test]
fn handle_request() {}
}