diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/args.rs | 10 | ||||
-rw-r--r-- | src/auth.rs | 74 | ||||
-rw-r--r-- | src/config.rs | 192 | ||||
-rw-r--r-- | src/file_upload.rs | 233 | ||||
-rw-r--r-- | src/listing.rs | 68 | ||||
-rw-r--r-- | src/main.rs | 218 | ||||
-rw-r--r-- | src/pipe.rs | 10 | ||||
-rw-r--r-- | src/renderer.rs | 4 |
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 |