aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/args.rs10
-rw-r--r--src/auth.rs74
-rw-r--r--src/config.rs192
-rw-r--r--src/file_upload.rs233
-rw-r--r--src/listing.rs68
-rw-r--r--src/main.rs218
-rw-r--r--src/pipe.rs10
-rw-r--r--src/renderer.rs4
8 files changed, 451 insertions, 358 deletions
diff --git a/src/args.rs b/src/args.rs
index 819618f..cea5658 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -133,6 +133,16 @@ pub struct CliArgs {
/// Generate completion file for a shell
#[structopt(long = "print-completions", value_name = "shell", possible_values = &structopt::clap::Shell::variants())]
pub print_completions: Option<structopt::clap::Shell>,
+
+ /// TLS certificate to use
+ #[cfg(feature = "tls")]
+ #[structopt(long = "tls-cert", requires = "tls-key")]
+ pub tls_cert: Option<PathBuf>,
+
+ /// TLS private key to use
+ #[cfg(feature = "tls")]
+ #[structopt(long = "tls-key", requires = "tls-cert")]
+ pub tls_key: Option<PathBuf>,
}
/// Checks wether an interface is valid, i.e. it can be parsed into an IP address
diff --git a/src/auth.rs b/src/auth.rs
index 1a913d5..7c77758 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,8 +1,9 @@
-use actix_web::dev::ServiceRequest;
+use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
use actix_web::http::{header, StatusCode};
-use actix_web::{HttpRequest, HttpResponse, Result};
-use actix_web_httpauth::extractors::basic::BasicAuth;
+use actix_web::{HttpRequest, HttpResponse};
+use futures::future::Either;
use sha2::{Digest, Sha256, Sha512};
+use std::future::{ready, Future};
use crate::errors::{self, ContextualError};
use crate::renderer;
@@ -14,12 +15,16 @@ pub struct BasicAuthParams {
pub password: String,
}
-impl From<BasicAuth> for BasicAuthParams {
- fn from(auth: BasicAuth) -> Self {
- Self {
+impl BasicAuthParams {
+ fn try_from_request(req: &HttpRequest) -> actix_web::Result<Self> {
+ use actix_web::http::header::Header;
+ use actix_web_httpauth::headers::authorization::{Authorization, Basic};
+
+ let auth = Authorization::<Basic>::parse(req)?.into_scheme();
+ Ok(Self {
username: auth.user_id().to_string(),
password: auth.password().unwrap_or(&"".into()).to_string(),
- }
+ })
}
}
@@ -72,25 +77,48 @@ pub fn get_hash<T: Digest>(text: &str) -> Vec<u8> {
hasher.finalize().to_vec()
}
-pub async fn handle_auth(req: ServiceRequest, cred: BasicAuth) -> Result<ServiceRequest> {
+/// When authentication succedes, return the request to be passed to downstream services.
+/// Otherwise, return an error response
+fn handle_auth(req: ServiceRequest) -> Result<ServiceRequest, ServiceResponse> {
let (req, pl) = req.into_parts();
let required_auth = &req.app_data::<crate::MiniserveConfig>().unwrap().auth;
- if match_auth(cred.into(), required_auth) {
- Ok(ServiceRequest::from_parts(req, pl).unwrap_or_else(|_| unreachable!()))
- } else {
- Err(HttpResponse::Unauthorized()
- .header(
- header::WWW_AUTHENTICATE,
- header::HeaderValue::from_static("Basic realm=\"miniserve\""),
- )
- .body(build_unauthorized_response(
- &req,
- ContextualError::InvalidHttpCredentials,
- true,
- StatusCode::UNAUTHORIZED,
- ))
- .into())
+ if required_auth.is_empty() {
+ // auth is disabled by configuration
+ return Ok(ServiceRequest::from_parts(req, pl));
+ } else if let Ok(cred) = BasicAuthParams::try_from_request(&req) {
+ if match_auth(cred, required_auth) {
+ return Ok(ServiceRequest::from_parts(req, pl));
+ }
+ }
+
+ // auth failed; render and return the error response
+ let resp = HttpResponse::Unauthorized()
+ .append_header((
+ header::WWW_AUTHENTICATE,
+ header::HeaderValue::from_static("Basic realm=\"miniserve\""),
+ ))
+ .body(build_unauthorized_response(
+ &req,
+ ContextualError::InvalidHttpCredentials,
+ true,
+ StatusCode::UNAUTHORIZED,
+ ));
+
+ Err(ServiceResponse::new(req, resp))
+}
+
+pub fn auth_middleware<S>(
+ req: ServiceRequest,
+ srv: &S,
+) -> impl Future<Output = actix_web::Result<ServiceResponse>> + 'static
+where
+ S: Service<ServiceRequest, Response = ServiceResponse, Error = actix_web::Error>,
+ S::Future: 'static,
+{
+ match handle_auth(req) {
+ Ok(req) => Either::Left(srv.call(req)),
+ Err(resp) => Either::Right(ready(Ok(resp))),
}
}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..e2b4c3a
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,192 @@
+use std::{
+ fs::File,
+ io::BufReader,
+ net::{IpAddr, Ipv4Addr, Ipv6Addr},
+ path::PathBuf,
+};
+
+use anyhow::{anyhow, Context, Result};
+use http::HeaderMap;
+
+#[cfg(feature = "tls")]
+use rustls::internal::pemfile::{certs, pkcs8_private_keys};
+
+use crate::{args::CliArgs, auth::RequiredAuth};
+
+/// Possible characters for random routes
+const ROUTE_ALPHABET: [char; 16] = [
+ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f',
+];
+
+#[derive(Clone)]
+/// Configuration of the Miniserve application
+pub struct MiniserveConfig {
+ /// Enable verbose mode
+ pub verbose: bool,
+
+ /// Path to be served by miniserve
+ pub path: std::path::PathBuf,
+
+ /// Port on which miniserve will be listening
+ pub port: u16,
+
+ /// IP address(es) on which miniserve will be available
+ pub interfaces: Vec<IpAddr>,
+
+ /// Enable HTTP basic authentication
+ pub auth: Vec<RequiredAuth>,
+
+ /// If false, miniserve will serve the current working directory
+ pub path_explicitly_chosen: bool,
+
+ /// Enable symlink resolution
+ pub no_symlinks: bool,
+
+ /// Show hidden files
+ pub show_hidden: bool,
+
+ /// Enable random route generation
+ pub random_route: Option<String>,
+
+ /// Randomly generated favicon route
+ pub favicon_route: String,
+
+ /// Randomly generated css route
+ pub css_route: String,
+
+ /// Default color scheme
+ pub default_color_scheme: String,
+
+ /// Default dark mode color scheme
+ pub default_color_scheme_dark: String,
+
+ /// The name of a directory index file to serve, like "index.html"
+ ///
+ /// Normally, when miniserve serves a directory, it creates a listing for that directory.
+ /// However, if a directory contains this file, miniserve will serve that file instead.
+ pub index: Option<std::path::PathBuf>,
+
+ /// Enable QR code display
+ pub show_qrcode: bool,
+
+ /// Enable file upload
+ pub file_upload: bool,
+
+ /// Enable upload to override existing files
+ pub overwrite_files: bool,
+
+ /// If false, creation of uncompressed tar archives is disabled
+ pub tar_enabled: bool,
+
+ /// If false, creation of gz-compressed tar archives is disabled
+ pub tar_gz_enabled: bool,
+
+ /// If false, creation of zip archives is disabled
+ pub zip_enabled: bool,
+
+ /// If enabled, directories are listed first
+ pub dirs_first: bool,
+
+ /// Shown instead of host in page title and heading
+ pub title: Option<String>,
+
+ /// If specified, header will be added
+ pub header: Vec<HeaderMap>,
+
+ /// If enabled, version footer is hidden
+ pub hide_version_footer: bool,
+
+ /// If set, use provided rustls config for TLS
+ #[cfg(feature = "tls")]
+ pub tls_rustls_config: Option<rustls::ServerConfig>,
+
+ #[cfg(not(feature = "tls"))]
+ pub tls_rustls_config: Option<()>,
+}
+
+impl MiniserveConfig {
+ /// Parses the command line arguments
+ pub fn try_from_args(args: CliArgs) -> Result<Self> {
+ let interfaces = if !args.interfaces.is_empty() {
+ args.interfaces
+ } else {
+ vec![
+ IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)),
+ IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
+ ]
+ };
+
+ let random_route = if args.random_route {
+ Some(nanoid::nanoid!(6, &ROUTE_ALPHABET))
+ } else {
+ None
+ };
+
+ // Generate some random routes for the favicon and css so that they are very unlikely to conflict with
+ // real files.
+ let favicon_route = nanoid::nanoid!(10, &ROUTE_ALPHABET);
+ let css_route = nanoid::nanoid!(10, &ROUTE_ALPHABET);
+
+ let default_color_scheme = args.color_scheme;
+ let default_color_scheme_dark = args.color_scheme_dark;
+
+ let path_explicitly_chosen = args.path.is_some() || args.index.is_some();
+
+ let port = match args.port {
+ 0 => port_check::free_local_port().context("No free ports available")?,
+ _ => args.port,
+ };
+
+ #[cfg(feature = "tls")]
+ let tls_rustls_server_config = if let (Some(tls_cert), Some(tls_key)) =
+ (args.tls_cert, args.tls_key)
+ {
+ let mut server_config = rustls::ServerConfig::new(rustls::NoClientAuth::new());
+ let cert_file = &mut BufReader::new(
+ File::open(&tls_cert)
+ .context(format!("Couldn't access TLS certificate {:?}", tls_cert))?,
+ );
+ let key_file = &mut BufReader::new(
+ File::open(&tls_key).context(format!("Couldn't access TLS key {:?}", tls_key))?,
+ );
+ let cert_chain = certs(cert_file).map_err(|_| anyhow!("Couldn't load certificates"))?;
+ let mut keys =
+ pkcs8_private_keys(key_file).map_err(|_| anyhow!("Couldn't load private key"))?;
+ server_config.set_single_cert(cert_chain, keys.remove(0))?;
+ Some(server_config)
+ } else {
+ None
+ };
+
+ #[cfg(not(feature = "tls"))]
+ let tls_rustls_server_config = None;
+
+ Ok(MiniserveConfig {
+ verbose: args.verbose,
+ path: args.path.unwrap_or_else(|| PathBuf::from(".")),
+ port,
+ interfaces,
+ auth: args.auth,
+ path_explicitly_chosen,
+ no_symlinks: args.no_symlinks,
+ show_hidden: args.hidden,
+ random_route,
+ favicon_route,
+ css_route,
+ default_color_scheme,
+ default_color_scheme_dark,
+ index: args.index,
+ overwrite_files: args.overwrite_files,
+ show_qrcode: args.qrcode,
+ file_upload: args.file_upload,
+ tar_enabled: args.enable_tar,
+ tar_gz_enabled: args.enable_tar_gz,
+ zip_enabled: args.enable_zip,
+ dirs_first: args.dirs_first,
+ title: args.title,
+ header: args.header,
+ hide_version_footer: args.hide_version_footer,
+ tls_rustls_config: tls_rustls_server_config,
+ })
+ }
+}
diff --git a/src/file_upload.rs b/src/file_upload.rs
index 93b7109..6fa99ef 100644
--- a/src/file_upload.rs
+++ b/src/file_upload.rs
@@ -2,11 +2,10 @@ use actix_web::{
http::{header, StatusCode},
HttpRequest, HttpResponse,
};
-use futures::{future, Future, FutureExt, Stream, TryStreamExt};
+use futures::TryStreamExt;
use std::{
io::Write,
path::{Component, PathBuf},
- pin::Pin,
};
use crate::errors::{self, ContextualError};
@@ -14,88 +13,62 @@ use crate::listing::{self, SortingMethod, SortingOrder};
use crate::renderer;
/// Create future to save file.
-fn save_file(
+async fn save_file(
field: actix_multipart::Field,
file_path: PathBuf,
overwrite_files: bool,
-) -> Pin<Box<dyn Future<Output = Result<i64, ContextualError>>>> {
+) -> Result<u64, ContextualError> {
if !overwrite_files && file_path.exists() {
- return Box::pin(future::err(ContextualError::DuplicateFileError));
+ return Err(ContextualError::DuplicateFileError);
}
- let mut file = match std::fs::File::create(&file_path) {
- Ok(file) => file,
- Err(e) => {
- return Box::pin(future::err(ContextualError::IoError(
- format!("Failed to create {}", file_path.display()),
- e,
- )));
- }
- };
- Box::pin(
- field
- .map_err(ContextualError::MultipartError)
- .try_fold(0i64, move |acc, bytes| {
- let rt = file
- .write_all(bytes.as_ref())
- .map(|_| acc + bytes.len() as i64)
- .map_err(|e| {
- ContextualError::IoError("Failed to write to file".to_string(), e)
- });
- future::ready(rt)
- }),
- )
+ let file = std::fs::File::create(&file_path).map_err(|e| {
+ ContextualError::IoError(format!("Failed to create {}", file_path.display()), e)
+ })?;
+
+ let (_, written_len) = field
+ .map_err(ContextualError::MultipartError)
+ .try_fold((file, 0u64), |(mut file, written_len), bytes| async move {
+ file.write_all(bytes.as_ref())
+ .map_err(|e| ContextualError::IoError("Failed to write to file".to_string(), e))?;
+ Ok((file, written_len + bytes.len() as u64))
+ })
+ .await?;
+
+ Ok(written_len)
}
/// Create new future to handle file as multipart data.
-fn handle_multipart(
+async fn handle_multipart(
field: actix_multipart::Field,
- mut file_path: PathBuf,
+ file_path: PathBuf,
overwrite_files: bool,
-) -> Pin<Box<dyn Stream<Item = Result<i64, ContextualError>>>> {
+) -> Result<u64, ContextualError> {
let filename = field
- .headers()
- .get(header::CONTENT_DISPOSITION)
- .ok_or(ContextualError::ParseError)
- .and_then(|cd| {
- header::ContentDisposition::from_raw(cd).map_err(|_| ContextualError::ParseError)
- })
- .and_then(|content_disposition| {
- content_disposition
- .get_filename()
- .ok_or(ContextualError::ParseError)
- .map(String::from)
- });
- let err = |e: ContextualError| Box::pin(future::err(e).into_stream());
- match filename {
- Ok(f) => {
- match std::fs::metadata(&file_path) {
- Ok(metadata) => {
- if !metadata.is_dir() {
- return err(ContextualError::InvalidPathError(format!(
- "cannot upload file to {}, since it's not a directory",
- &file_path.display()
- )));
- } else if metadata.permissions().readonly() {
- return err(ContextualError::InsufficientPermissionsError(
- file_path.display().to_string(),
- ));
- }
- }
- Err(_) => {
- return err(ContextualError::InsufficientPermissionsError(
- file_path.display().to_string(),
- ));
- }
- }
- file_path = file_path.join(f);
- Box::pin(save_file(field, file_path, overwrite_files).into_stream())
- }
- Err(e) => err(e(
- "HTTP header".to_string(),
- "Failed to retrieve the name of the file to upload".to_string(),
+ .content_disposition()
+ .and_then(|cd| cd.get_filename().map(String::from))
+ .ok_or_else(|| {
+ ContextualError::ParseError(
+ "HTTP header".to_string(),
+ "Failed to retrieve the name of the file to upload".to_string(),
+ )
+ })?;
+
+ match std::fs::metadata(&file_path) {
+ Err(_) => Err(ContextualError::InsufficientPermissionsError(
+ file_path.display().to_string(),
)),
- }
+ Ok(metadata) if !metadata.is_dir() => Err(ContextualError::InvalidPathError(format!(
+ "cannot upload file to {}, since it's not a directory",
+ &file_path.display()
+ ))),
+ Ok(metadata) if metadata.permissions().readonly() => Err(
+ ContextualError::InsufficientPermissionsError(file_path.display().to_string()),
+ ),
+ Ok(_) => Ok(()),
+ }?;
+
+ save_file(field, file_path.join(filename), overwrite_files).await
}
/// Handle incoming request to upload file.
@@ -104,16 +77,16 @@ fn handle_multipart(
/// invalid.
/// This method returns future.
#[allow(clippy::too_many_arguments)]
-pub fn upload_file(
+pub async fn upload_file(
req: HttpRequest,
payload: actix_web::web::Payload,
uses_random_route: bool,
favicon_route: String,
css_route: String,
- default_color_scheme: &str,
- default_color_scheme_dark: &str,
+ default_color_scheme: String,
+ default_color_scheme_dark: String,
hide_version_footer: bool,
-) -> Pin<Box<dyn Future<Output = Result<HttpResponse, actix_web::Error>>>> {
+) -> Result<HttpResponse, actix_web::Error> {
let conf = req.app_data::<crate::MiniserveConfig>().unwrap();
let return_path = if let Some(header) = req.headers().get(header::REFERER) {
header.to_str().unwrap_or("/").to_owned()
@@ -131,7 +104,7 @@ pub fn upload_file(
let err = ContextualError::InvalidHttpRequestError(
"Missing query parameter 'path'".to_string(),
);
- return Box::pin(create_error_response(
+ return Ok(create_error_response(
&err.to_string(),
StatusCode::BAD_REQUEST,
&return_path,
@@ -140,8 +113,8 @@ pub fn upload_file(
uses_random_route,
&favicon_route,
&css_route,
- default_color_scheme,
- default_color_scheme_dark,
+ &default_color_scheme,
+ &default_color_scheme_dark,
hide_version_footer,
));
}
@@ -154,7 +127,7 @@ pub fn upload_file(
"Failed to resolve path served by miniserve".to_string(),
e,
);
- return Box::pin(create_error_response(
+ return Ok(create_error_response(
&err.to_string(),
StatusCode::INTERNAL_SERVER_ERROR,
&return_path,
@@ -163,8 +136,8 @@ pub fn upload_file(
uses_random_route,
&favicon_route,
&css_route,
- default_color_scheme,
- default_color_scheme_dark,
+ &default_color_scheme,
+ &default_color_scheme_dark,
hide_version_footer,
));
}
@@ -177,7 +150,7 @@ pub fn upload_file(
let err = ContextualError::InvalidHttpRequestError(
"Invalid value for 'path' parameter".to_string(),
);
- return Box::pin(create_error_response(
+ return Ok(create_error_response(
&err.to_string(),
StatusCode::BAD_REQUEST,
&return_path,
@@ -186,8 +159,8 @@ pub fn upload_file(
uses_random_route,
&favicon_route,
&css_route,
- default_color_scheme,
- default_color_scheme_dark,
+ &default_color_scheme,
+ &default_color_scheme_dark,
hide_version_footer,
));
}
@@ -196,33 +169,29 @@ pub fn upload_file(
let default_color_scheme = conf.default_color_scheme.clone();
let default_color_scheme_dark = conf.default_color_scheme_dark.clone();
- Box::pin(
- actix_multipart::Multipart::new(req.headers(), payload)
- .map_err(ContextualError::MultipartError)
- .map_ok(move |item| handle_multipart(item, target_dir.clone(), overwrite_files))
- .try_flatten()
- .try_collect::<Vec<_>>()
- .then(move |e| match e {
- Ok(_) => future::ok(
- HttpResponse::SeeOther()
- .header(header::LOCATION, return_path)
- .finish(),
- ),
- Err(e) => create_error_response(
- &e.to_string(),
- StatusCode::INTERNAL_SERVER_ERROR,
- &return_path,
- query_params.sort,
- query_params.order,
- uses_random_route,
- &favicon_route,
- &css_route,
- &default_color_scheme,
- &default_color_scheme_dark,
- hide_version_footer,
- ),
- }),
- )
+ match actix_multipart::Multipart::new(req.headers(), payload)
+ .map_err(ContextualError::MultipartError)
+ .and_then(move |field| handle_multipart(field, target_dir.clone(), overwrite_files))
+ .try_collect::<Vec<u64>>()
+ .await
+ {
+ Ok(_) => Ok(HttpResponse::SeeOther()
+ .append_header((header::LOCATION, return_path))
+ .finish()),
+ Err(e) => Ok(create_error_response(
+ &e.to_string(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ &return_path,
+ query_params.sort,
+ query_params.order,
+ uses_random_route,
+ &favicon_route,
+ &css_route,
+ &default_color_scheme,
+ &default_color_scheme_dark,
+ hide_version_footer,
+ )),
+ }
}
/// Convenience method for creating response errors, if file upload fails.
@@ -239,27 +208,25 @@ fn create_error_response(
default_color_scheme: &str,
default_color_scheme_dark: &str,
hide_version_footer: bool,
-) -> future::Ready<Result<HttpResponse, actix_web::Error>> {
+) -> HttpResponse {
errors::log_error_chain(description.to_string());
- future::ok(
- HttpResponse::BadRequest()
- .content_type("text/html; charset=utf-8")
- .body(
- renderer::render_error(
- description,
- error_code,
- return_path,
- sorting_method,
- sorting_order,
- true,
- !uses_random_route,
- &favicon_route,
- &css_route,
- default_color_scheme,
- default_color_scheme_dark,
- hide_version_footer,
- )
- .into_string(),
- ),
- )
+ HttpResponse::BadRequest()
+ .content_type("text/html; charset=utf-8")
+ .body(
+ renderer::render_error(
+ description,
+ error_code,
+ return_path,
+ sorting_method,
+ sorting_order,
+ true,
+ !uses_random_route,
+ favicon_route,
+ css_route,
+ default_color_scheme,
+ default_color_scheme_dark,
+ hide_version_footer,
+ )
+ .into_string(),
+ )
}
diff --git a/src/listing.rs b/src/listing.rs
index 43cfb0e..33a0342 100644
--- a/src/listing.rs
+++ b/src/listing.rs
@@ -2,7 +2,7 @@ use actix_web::body::Body;
use actix_web::dev::ServiceResponse;
use actix_web::http::StatusCode;
use actix_web::web::Query;
-use actix_web::{HttpRequest, HttpResponse, Result};
+use actix_web::{HttpRequest, HttpResponse};
use bytesize::ByteSize;
use percent_encoding::{percent_decode_str, utf8_percent_encode};
use qrcodegen::{QrCode, QrCodeEcc};
@@ -24,7 +24,7 @@ mod percent_encode_sets {
const BASE: &AsciiSet = &CONTROLS.add(b'%');
pub const QUERY: &AsciiSet = &BASE.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>');
pub const PATH: &AsciiSet = &QUERY.add(b'?').add(b'`').add(b'{').add(b'}');
- pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/');
+ pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'\\');
}
/// Query parameters
@@ -142,7 +142,7 @@ impl Breadcrumb {
}
}
-pub async fn file_handler(req: HttpRequest) -> Result<actix_files::NamedFile> {
+pub async fn file_handler(req: HttpRequest) -> actix_web::Result<actix_files::NamedFile> {
let path = &req.app_data::<crate::MiniserveConfig>().unwrap().path;
actix_files::NamedFile::open(path).map_err(Into::into)
}
@@ -169,25 +169,10 @@ pub fn directory_listing(
dirs_first: bool,
hide_version_footer: bool,
title: Option<String>,
-) -> Result<ServiceResponse, io::Error> {
+) -> io::Result<ServiceResponse> {
use actix_web::dev::BodyEncoding;
let serve_path = req.path();
- // In case the current path is a directory, we want to make sure that the current URL ends
- // on a slash ("/").
- if !serve_path.ends_with('/') {
- let query = match req.query_string() {
- "" => String::new(),
- _ => format!("?{}", req.query_string()),
- };
- return Ok(ServiceResponse::new(
- req.clone(),
- HttpResponse::MovedPermanently()
- .header("Location", format!("{}/{}", serve_path, query))
- .body("301"),
- ));
- }
-
let base = Path::new(serve_path);
let random_route_abs = format!("/{}", random_route.clone().unwrap_or_default());
let is_root = base.parent().is_none() || Path::new(&req.path()) == Path::new(&random_route_abs);
@@ -243,10 +228,10 @@ pub fn directory_listing(
if let Some(url) = query_params.qrcode {
let res = match QrCode::encode_text(&url, QrCodeEcc::Medium) {
Ok(qr) => HttpResponse::Ok()
- .header("Content-Type", "image/svg+xml")
- .body(qr.to_svg_string(2)),
+ .append_header(("Content-Type", "image/svg+xml"))
+ .body(qr_to_svg_string(&qr, 2)),
Err(err) => {
- log::error!("URL is too long: {:?}", err);
+ log::error!("URL is invalid (too long?): {:?}", err);
HttpResponse::UriTooLong().body(Body::Empty)
}
};
@@ -376,7 +361,7 @@ pub fn directory_listing(
// We will create the archive in a separate thread, and stream the content using a pipe.
// The pipe is made of a futures channel, and an adapter to implement the `Write` trait.
// Include 10 messages of buffer for erratic connection speeds.
- let (tx, rx) = futures::channel::mpsc::channel::<Result<actix_web::web::Bytes, ()>>(10);
+ let (tx, rx) = futures::channel::mpsc::channel::<io::Result<actix_web::web::Bytes>>(10);
let pipe = crate::pipe::Pipe::new(tx);
// Start the actual archive creation in a separate thread.
@@ -392,11 +377,11 @@ pub fn directory_listing(
HttpResponse::Ok()
.content_type(archive_method.content_type())
.encoding(archive_method.content_encoding())
- .header("Content-Transfer-Encoding", "binary")
- .header(
+ .append_header(("Content-Transfer-Encoding", "binary"))
+ .append_header((
"Content-Disposition",
format!("attachment; filename={:?}", file_name),
- )
+ ))
.body(actix_web::body::BodyStream::new(rx)),
))
} else {
@@ -452,3 +437,34 @@ pub fn extract_query_parameters(req: &HttpRequest) -> QueryParameters {
}
}
}
+
+// Returns a string of SVG code for an image depicting
+// the given QR Code, with the given number of border modules.
+// The string always uses Unix newlines (\n), regardless of the platform.
+fn qr_to_svg_string(qr: &QrCode, border: i32) -> String {
+ assert!(border >= 0, "Border must be non-negative");
+ let mut result = String::new();
+ result += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
+ result += "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n";
+ let dimension = qr
+ .size()
+ .checked_add(border.checked_mul(2).unwrap())
+ .unwrap();
+ result += &format!(
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {0} {0}\" stroke=\"none\">\n", dimension);
+ result += "\t<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n";
+ result += "\t<path d=\"";
+ for y in 0..qr.size() {
+ for x in 0..qr.size() {
+ if qr.get_module(x, y) {
+ if x != 0 || y != 0 {
+ result += " ";
+ }
+ result += &format!("M{},{}h1v1h-1z", x + border, y + border);
+ }
+ }
+ }
+ result += "\" fill=\"#000000\"/>\n";
+ result += "</svg>\n";
+ result
+}
diff --git a/src/main.rs b/src/main.rs
index b6dd856..149f1ea 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,8 @@
use std::io;
-use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
+use std::io::Write;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::thread;
use std::time::Duration;
-use std::{io::Write, path::PathBuf};
use actix_web::web;
use actix_web::{
@@ -10,8 +10,7 @@ use actix_web::{
Responder,
};
use actix_web::{middleware, App, HttpRequest, HttpResponse};
-use actix_web_httpauth::middleware::HttpAuthentication;
-use http::header::HeaderMap;
+use anyhow::Result;
use log::{error, warn};
use structopt::clap::crate_version;
use structopt::StructOpt;
@@ -20,174 +19,31 @@ use yansi::{Color, Paint};
mod archive;
mod args;
mod auth;
+mod config;
mod errors;
mod file_upload;
mod listing;
mod pipe;
mod renderer;
+use crate::config::MiniserveConfig;
use crate::errors::ContextualError;
-/// Possible characters for random routes
-const ROUTE_ALPHABET: [char; 16] = [
- '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f',
-];
-
-#[derive(Clone)]
-/// Configuration of the Miniserve application
-pub struct MiniserveConfig {
- /// Enable verbose mode
- pub verbose: bool,
-
- /// Path to be served by miniserve
- pub path: std::path::PathBuf,
-
- /// Port on which miniserve will be listening
- pub port: u16,
-
- /// IP address(es) on which miniserve will be available
- pub interfaces: Vec<IpAddr>,
-
- /// Enable HTTP basic authentication
- pub auth: Vec<auth::RequiredAuth>,
-
- /// If false, miniserve will serve the current working directory
- pub path_explicitly_chosen: bool,
-
- /// Enable symlink resolution
- pub no_symlinks: bool,
-
- /// Show hidden files
- pub show_hidden: bool,
-
- /// Enable random route generation
- pub random_route: Option<String>,
-
- /// Randomly generated favicon route
- pub favicon_route: String,
-
- /// Randomly generated css route
- pub css_route: String,
-
- /// Default color scheme
- pub default_color_scheme: String,
-
- /// Default dark mode color scheme
- pub default_color_scheme_dark: String,
-
- /// The name of a directory index file to serve, like "index.html"
- ///
- /// Normally, when miniserve serves a directory, it creates a listing for that directory.
- /// However, if a directory contains this file, miniserve will serve that file instead.
- pub index: Option<std::path::PathBuf>,
-
- /// Enable QR code display
- pub show_qrcode: bool,
-
- /// Enable file upload
- pub file_upload: bool,
-
- /// Enable upload to override existing files
- pub overwrite_files: bool,
-
- /// If false, creation of uncompressed tar archives is disabled
- pub tar_enabled: bool,
-
- /// If false, creation of gz-compressed tar archives is disabled
- pub tar_gz_enabled: bool,
-
- /// If false, creation of zip archives is disabled
- pub zip_enabled: bool,
-
- /// If enabled, directories are listed first
- pub dirs_first: bool,
-
- /// Shown instead of host in page title and heading
- pub title: Option<String>,
-
- /// If specified, header will be added
- pub header: Vec<HeaderMap>,
-
- /// If enabled, version footer is hidden
- pub hide_version_footer: bool,
-}
-
-impl MiniserveConfig {
- /// Parses the command line arguments
- fn from_args(args: args::CliArgs) -> Self {
- let interfaces = if !args.interfaces.is_empty() {
- args.interfaces
- } else {
- vec![
- IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)),
- IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
- ]
- };
-
- let random_route = if args.random_route {
- Some(nanoid::nanoid!(6, &ROUTE_ALPHABET))
- } else {
- None
- };
-
- // Generate some random routes for the favicon and css so that they are very unlikely to conflict with
- // real files.
- let favicon_route = nanoid::nanoid!(10, &ROUTE_ALPHABET);
- let css_route = nanoid::nanoid!(10, &ROUTE_ALPHABET);
-
- let default_color_scheme = args.color_scheme;
- let default_color_scheme_dark = args.color_scheme_dark;
-
- let path_explicitly_chosen = args.path.is_some() || args.index.is_some();
-
- let port = match args.port {
- 0 => port_check::free_local_port().expect("no free ports available"),
- _ => args.port,
- };
-
- crate::MiniserveConfig {
- verbose: args.verbose,
- path: args.path.unwrap_or_else(|| PathBuf::from(".")),
- port,
- interfaces,
- auth: args.auth,
- path_explicitly_chosen,
- no_symlinks: args.no_symlinks,
- show_hidden: args.hidden,
- random_route,
- favicon_route,
- css_route,
- default_color_scheme,
- default_color_scheme_dark,
- index: args.index,
- overwrite_files: args.overwrite_files,
- show_qrcode: args.qrcode,
- file_upload: args.file_upload,
- tar_enabled: args.enable_tar,
- tar_gz_enabled: args.enable_tar_gz,
- zip_enabled: args.enable_zip,
- dirs_first: args.dirs_first,
- title: args.title,
- header: args.header,
- hide_version_footer: args.hide_version_footer,
- }
- }
-}
-
-fn main() {
+fn main() -> Result<()> {
let args = args::CliArgs::from_args();
if let Some(shell) = args.print_completions {
args::CliArgs::clap().gen_completions_to("miniserve", shell, &mut std::io::stdout());
- return;
+ return Ok(());
}
- let miniserve_config = MiniserveConfig::from_args(args);
+ let miniserve_config = MiniserveConfig::try_from_args(args)?;
match run(miniserve_config) {
Ok(()) => (),
Err(e) => errors::log_error_chain(e.to_string()),
}
+ Ok(())
}
#[actix_web::main(miniserve)]
@@ -300,11 +156,17 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), ContextualError> {
if !addresses.is_empty() {
addresses.push_str(", ");
}
+ let protocol = if miniserve_config.tls_rustls_config.is_some() {
+ "https"
+ } else {
+ "http"
+ };
addresses.push_str(&format!(
"{}",
Color::Green
.paint(format!(
- "http://{interface}:{port}",
+ "{protocol}://{interface}:{port}",
+ protocol = protocol,
interface = &interface,
port = miniserve_config.port
))
@@ -350,10 +212,11 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), ContextualError> {
App::new()
.wrap(configure_header(&inside_config.clone()))
.app_data(inside_config.clone())
- .wrap(middleware::Condition::new(
- !inside_config.auth.is_empty(),
- HttpAuthentication::basic(auth::handle_auth),
- ))
+ // we should use `actix_web_httpauth::middleware::HttpAuthentication`
+ // but it is unfortuantrly broken
+ // see: https://github.com/actix/actix-extras/issues/127
+ // TODO replace this when fixed upstream
+ .wrap_fn(auth::auth_middleware)
.wrap(middleware::Logger::default())
.route(
&format!("/{}", inside_config.favicon_route),
@@ -362,11 +225,27 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), ContextualError> {
.route(&format!("/{}", inside_config.css_route), web::get().to(css))
.configure(|c| configure_app(c, &inside_config))
.default_service(web::get().to(error_404))
- })
- .bind(socket_addresses.as_slice())
- .map_err(|e| ContextualError::IoError("Failed to bind server".to_string(), e))?
- .shutdown_timeout(0)
- .run();
+ });
+
+ #[cfg(feature = "tls")]
+ let srv = if let Some(tls_config) = miniserve_config.tls_rustls_config {
+ srv.bind_rustls(socket_addresses.as_slice(), tls_config)
+ .map_err(|e| ContextualError::IoError("Failed to bind server".to_string(), e))?
+ .shutdown_timeout(0)
+ .run()
+ } else {
+ srv.bind(socket_addresses.as_slice())
+ .map_err(|e| ContextualError::IoError("Failed to bind server".to_string(), e))?
+ .shutdown_timeout(0)
+ .run()
+ };
+
+ #[cfg(not(feature = "tls"))]
+ let srv = srv
+ .bind(socket_addresses.as_slice())
+ .map_err(|e| ContextualError::IoError("Failed to bind server".to_string(), e))?
+ .shutdown_timeout(0)
+ .run();
println!(
"Serving path {path} at {addresses}",
@@ -466,6 +345,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) {
)
})
.prefer_utf8(true)
+ .redirect_to_slash_directory()
.default_handler(web::to(error_404));
Some(files)
}
@@ -489,8 +369,8 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) {
uses_random_route,
favicon_route.clone(),
css_route.clone(),
- &default_color_scheme,
- &default_color_scheme_dark,
+ default_color_scheme.clone(),
+ default_color_scheme_dark.clone(),
hide_version_footer,
)
})),
@@ -538,14 +418,14 @@ async fn error_404(req: HttpRequest) -> HttpResponse {
async fn favicon() -> impl Responder {
let logo = include_str!("../data/logo.svg");
- web::HttpResponse::Ok()
- .set(ContentType(mime::IMAGE_SVG))
+ HttpResponse::Ok()
+ .insert_header(ContentType(mime::IMAGE_SVG))
.message_body(logo.into())
}
async fn css() -> impl Responder {
let css = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
- web::HttpResponse::Ok()
- .set(ContentType(mime::TEXT_CSS))
+ HttpResponse::Ok()
+ .insert_header(ContentType(mime::TEXT_CSS))
.message_body(css.into())
}
diff --git a/src/pipe.rs b/src/pipe.rs
index 374a45f..6bf32c2 100644
--- a/src/pipe.rs
+++ b/src/pipe.rs
@@ -3,19 +3,19 @@ use actix_web::web::{Bytes, BytesMut};
use futures::channel::mpsc::Sender;
use futures::executor::block_on;
use futures::sink::SinkExt;
-use std::io::{Error, ErrorKind, Result, Write};
+use std::io::{self, Error, ErrorKind, Write};
/// Adapter to implement the `std::io::Write` trait on a `Sender<Bytes>` from a futures channel.
///
/// It uses an intermediate buffer to transfer packets.
pub struct Pipe {
- dest: Sender<std::result::Result<Bytes, ()>>,
+ dest: Sender<io::Result<Bytes>>,
bytes: BytesMut,
}
impl Pipe {
/// Wrap the given sender in a `Pipe`.
- pub fn new(destination: Sender<std::result::Result<Bytes, ()>>) -> Self {
+ pub fn new(destination: Sender<io::Result<Bytes>>) -> Self {
Pipe {
dest: destination,
bytes: BytesMut::new(),
@@ -30,7 +30,7 @@ impl Drop for Pipe {
}
impl Write for Pipe {
- fn write(&mut self, buf: &[u8]) -> Result<usize> {
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// We are given a slice of bytes we do not own, so we must start by copying it.
self.bytes.extend_from_slice(buf);
@@ -42,7 +42,7 @@ impl Write for Pipe {
Ok(buf.len())
}
- fn flush(&mut self) -> Result<()> {
+ fn flush(&mut self) -> io::Result<()> {
block_on(self.dest.flush()).map_err(|e| Error::new(ErrorKind::UnexpectedEof, e))
}
}
diff --git a/src/renderer.rs b/src/renderer.rs
index d2beda3..66f0291 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -272,7 +272,7 @@ fn parametrized_link(
if let Some(order) = sort_order {
let parametrized_link = format!(
"{}?sort={}&order={}",
- make_link_with_trailing_slash(&link),
+ make_link_with_trailing_slash(link),
method,
order
);
@@ -281,7 +281,7 @@ fn parametrized_link(
}
}
- make_link_with_trailing_slash(&link)
+ make_link_with_trailing_slash(link)
}
/// Partial: table header link