use actix_web::http::header; use actix_web::middleware::{Middleware, Response}; use actix_web::{fs, middleware, server, App, HttpMessage, HttpRequest, HttpResponse, Result}; use bytesize::ByteSize; use clap::{crate_authors, crate_description, crate_name, crate_version}; use htmlescape::encode_minimal as escape_html_entity; use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; use simplelog::{Config, LevelFilter, TermLogger}; use std::cmp::Ordering; use std::fmt::Write as FmtWrite; use std::io::{self, Write}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::thread; use std::time::Duration; use yansi::{Color, Paint}; const ROUTE_ALPHABET: [char; 16] = [ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', ]; enum BasicAuthError { Base64DecodeError, InvalidUsernameFormat, } #[derive(Clone, Debug)] struct BasicAuthParams { username: String, password: String, } #[derive(Clone, Debug)] enum SortingMethods { Natural, Alpha, DirsFirst, } #[derive(Clone, Debug)] pub struct MiniserveConfig { verbose: bool, path: std::path::PathBuf, port: u16, interfaces: Vec, auth: Option, path_explicitly_chosen: bool, no_symlinks: bool, random_route: Option, sort_method: SortingMethods, reverse_sort: bool, } #[derive(PartialEq)] enum EntryType { Directory, File, } impl PartialOrd for EntryType { fn partial_cmp(&self, other: &EntryType) -> Option { match (self, other) { (EntryType::Directory, EntryType::File) => Some(Ordering::Less), (EntryType::File, EntryType::Directory) => Some(Ordering::Greater), _ => Some(Ordering::Equal), } } } struct Entry { name: String, entry_type: EntryType, link: String, size: Option, } impl Entry { fn new( name: String, entry_type: EntryType, link: String, size: Option, ) -> Self { Entry { name, entry_type, link, size, } } } impl FromStr for SortingMethods { type Err = (); fn from_str(s: &str) -> Result { match s { "natural" => Ok(SortingMethods::Natural), "alpha" => Ok(SortingMethods::Alpha), "dirsfirst" => Ok(SortingMethods::DirsFirst), _ => Err(()), } } } /// Decode a HTTP basic auth string into a tuple of username and password. fn parse_basic_auth( authorization_header: &header::HeaderValue, ) -> Result { let basic_removed = authorization_header.to_str().unwrap().replace("Basic ", ""); let decoded = base64::decode(&basic_removed).map_err(|_| BasicAuthError::Base64DecodeError)?; let decoded_str = String::from_utf8_lossy(&decoded); let strings: Vec<&str> = decoded_str.splitn(2, ':').collect(); if strings.len() != 2 { return Err(BasicAuthError::InvalidUsernameFormat); } Ok(BasicAuthParams { username: strings[0].to_owned(), password: strings[1].to_owned(), }) } fn is_valid_path(path: String) -> Result<(), String> { let path_to_check = PathBuf::from(path); if path_to_check.is_file() || path_to_check.is_dir() { return Ok(()); } Err(String::from( "Path either doesn't exist or is not a regular file or a directory", )) } fn is_valid_port(port: String) -> Result<(), String> { port.parse::() .and(Ok(())) .or_else(|e| Err(e.to_string())) } fn is_valid_interface(interface: String) -> Result<(), String> { interface .parse::() .and(Ok(())) .or_else(|e| Err(e.to_string())) } fn is_valid_auth(auth: String) -> Result<(), String> { auth.find(':') .ok_or_else(|| "Correct format is username:password".to_owned()) .map(|_| ()) } pub fn parse_args() -> MiniserveConfig { use clap::{App, AppSettings, Arg}; let matches = App::new(crate_name!()) .version(crate_version!()) .author(crate_authors!()) .about(crate_description!()) .global_setting(AppSettings::ColoredHelp) .arg( Arg::with_name("verbose") .short("v") .long("verbose") .help("Be verbose, includes emitting access logs"), ) .arg( Arg::with_name("PATH") .required(false) .validator(is_valid_path) .help("Which path to serve"), ) .arg( Arg::with_name("port") .short("p") .long("port") .help("Port to use") .validator(is_valid_port) .required(false) .default_value("8080") .takes_value(true), ) .arg( Arg::with_name("interfaces") .short("i") .long("if") .help("Interface to listen on") .validator(is_valid_interface) .required(false) .takes_value(true) .multiple(true), ) .arg( Arg::with_name("auth") .short("a") .long("auth") .validator(is_valid_auth) .help("Set authentication (username:password)") .takes_value(true), ) .arg( Arg::with_name("random-route") .long("random-route") .help("Generate a random 6-hexdigit route"), ) .arg( Arg::with_name("sort") .short("s") .long("sort") .possible_values(&["natural", "alpha", "dirsfirst"]) .default_value("natural") .help("Sort files"), ) .arg( Arg::with_name("reverse") .long("reverse") .help("Reverse sorting order"), ) .arg( Arg::with_name("no-symlinks") .short("P") .long("no-symlinks") .help("Do not follow symbolic links"), ) .get_matches(); let verbose = matches.is_present("verbose"); let no_symlinks = matches.is_present("no-symlinks"); let path = matches.value_of("PATH"); let port = matches.value_of("port").unwrap().parse().unwrap(); let interfaces = if let Some(interfaces) = matches.values_of("interfaces") { interfaces.map(|x| x.parse().unwrap()).collect() } else { vec![ IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), ] }; let auth = if let Some(auth_split) = matches.value_of("auth").map(|x| x.splitn(2, ':')) { let auth_vec = auth_split.collect::>(); if auth_vec.len() == 2 { Some(BasicAuthParams { username: auth_vec[0].to_owned(), password: auth_vec[1].to_owned(), }) } else { None } } else { None }; let random_route = if matches.is_present("random-route") { Some(nanoid::custom(6, &ROUTE_ALPHABET)) } else { None }; let sort_method = matches .value_of("sort") .unwrap() .parse::() .unwrap(); let reverse_sort = matches.is_present("reverse"); MiniserveConfig { verbose, path: PathBuf::from(path.unwrap_or(".")), port, interfaces, auth, path_explicitly_chosen: path.is_some(), no_symlinks, random_route, sort_method, reverse_sort, } } fn file_handler(req: &HttpRequest) -> Result { let path = &req.state().path; Ok(fs::NamedFile::open(path)?) } fn configure_app(app: App) -> App { let s = { let path = &app.state().path; let no_symlinks = app.state().no_symlinks; let random_route = app.state().random_route.clone(); let sort_method = app.state().sort_method.clone(); let reverse_sort = app.state().reverse_sort; if path.is_file() { None } else { Some( fs::StaticFiles::new(path) .expect("Couldn't create path") .show_files_listing() .files_listing_renderer(move |dir, req| { directory_listing( dir, req, no_symlinks, random_route.clone(), sort_method.clone(), reverse_sort, ) }), ) } }; let random_route = app.state().random_route.clone().unwrap_or_default(); let full_route = format!("/{}", random_route); if let Some(s) = s { app.handler(&full_route, s) } else { app.resource(&full_route, |r| r.f(file_handler)) } } struct Auth; impl Middleware for Auth { fn response(&self, req: &HttpRequest, resp: HttpResponse) -> Result { if let Some(ref required_auth) = req.state().auth { if let Some(auth_headers) = req.headers().get(header::AUTHORIZATION) { let auth_req = match parse_basic_auth(auth_headers) { Ok(auth_req) => auth_req, Err(BasicAuthError::Base64DecodeError) => { return Ok(Response::Done(HttpResponse::BadRequest().body(format!( "Error decoding basic auth base64: '{}'", auth_headers.to_str().unwrap() )))); } Err(BasicAuthError::InvalidUsernameFormat) => { return Ok(Response::Done( HttpResponse::BadRequest().body("Invalid basic auth format"), )); } }; if auth_req.username != required_auth.username || auth_req.password != required_auth.password { let new_resp = HttpResponse::Forbidden().finish(); return Ok(Response::Done(new_resp)); } } else { let new_resp = HttpResponse::Unauthorized() .header( header::WWW_AUTHENTICATE, header::HeaderValue::from_static("Basic realm=\"miniserve\""), ) .finish(); return Ok(Response::Done(new_resp)); } } Ok(Response::Done(resp)) } } fn main() { if cfg!(windows) && !Paint::enable_windows_ascii() { Paint::disable(); } let miniserve_config = parse_args(); if miniserve_config.no_symlinks && miniserve_config .path .symlink_metadata() .expect("Can't get file metadata") .file_type() .is_symlink() { println!( "{error} The no-symlinks option cannot be used with a symlink path", error = Paint::red("error:").bold(), ); return; } if miniserve_config.verbose { let _ = TermLogger::init(LevelFilter::Info, Config::default()); } let sys = actix::System::new("miniserve"); let inside_config = miniserve_config.clone(); server::new(move || { App::with_state(inside_config.clone()) .middleware(Auth) .middleware(middleware::Logger::default()) .configure(configure_app) }) .bind( miniserve_config .interfaces .iter() .map(|interface| { format!( "{interface}:{port}", interface = &interface, port = miniserve_config.port, ) .to_socket_addrs() .unwrap() .next() .unwrap() }) .collect::>() .as_slice(), ) .expect("Couldn't bind server") .shutdown_timeout(0) .start(); let interfaces = miniserve_config.interfaces.iter().map(|&interface| { if interface == IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) { // If the interface is 0.0.0.0, we'll change it to localhost so that clicking the link will // also work on Windows. Why can't Windows interpret 0.0.0.0? String::from("localhost") } else if interface.is_ipv6() { // If the interface is IPv6 then we'll print it with brackets so that it is clickable. format!("[{}]", interface) } else { format!("{}", interface) } }); let canon_path = miniserve_config.path.canonicalize().unwrap(); let path_string = canon_path.to_string_lossy(); println!( "{name} v{version}", name = Paint::new("miniserve").bold(), version = crate_version!() ); if !miniserve_config.path_explicitly_chosen { println!("{info} miniserve has been invoked without an explicit path so it will serve the current directory.", info=Color::Blue.paint("Info:").bold()); println!( " Invoke with -h|--help to see options or invoke as `miniserve .` to hide this advice." ); print!("Starting server in "); io::stdout().flush().unwrap(); for c in "3… 2… 1… \n".chars() { print!("{}", c); io::stdout().flush().unwrap(); thread::sleep(Duration::from_millis(500)); } } let mut addresses = String::new(); for interface in interfaces { if !addresses.is_empty() { addresses.push_str(", "); } addresses.push_str(&format!( "{}", Color::Green .paint(format!( "http://{interface}:{port}", interface = interface, port = miniserve_config.port )) .bold() )); let random_route = miniserve_config.clone().random_route; if random_route.is_some() { addresses.push_str(&format!( "{}", Color::Green .paint(format!( "/{random_route}", random_route = random_route.unwrap(), )) .bold() )); } } println!( "Serving path {path} at {addresses}", path = Color::Yellow.paint(path_string).bold(), addresses = addresses, ); println!("Quit by pressing CTRL-C"); let _ = sys.run(); } // ↓ Adapted from https://docs.rs/actix-web/0.7.13/src/actix_web/fs.rs.html#564 fn directory_listing( dir: &fs::Directory, req: &HttpRequest, skip_symlinks: bool, random_route: Option, sort_method: SortingMethods, reverse_sort: bool, ) -> Result { let index_of = format!("Index of {}", req.path()); let mut body = String::new(); let base = Path::new(req.path()); let random_route = format!("/{}", random_route.unwrap_or_default()); if let Some(parent) = base.parent() { if req.path() != random_route { let _ = write!( body, "..", parent.display() ); } } let mut entries: Vec = Vec::new(); for entry in dir.path.read_dir()? { if dir.is_visible(&entry) { let entry = entry.unwrap(); let p = match entry.path().strip_prefix(&dir.path) { Ok(p) => base.join(p), Err(_) => continue, }; // show file url as relative to static path let file_url = utf8_percent_encode(&p.to_string_lossy(), DEFAULT_ENCODE_SET).to_string(); // " -- " & -- & ' -- ' < -- < > -- > let file_name = escape_html_entity(&entry.file_name().to_string_lossy()); // if file is a directory, add '/' to the end of the name if let Ok(metadata) = entry.metadata() { if skip_symlinks && metadata.file_type().is_symlink() { continue; } if metadata.is_dir() { entries.push(Entry::new(file_name, EntryType::Directory, file_url, None)); } else { entries.push(Entry::new( file_name, EntryType::File, file_url, Some(ByteSize::b(metadata.len())), )); } } else { continue; } } } match sort_method { SortingMethods::Natural => entries .sort_by(|e1, e2| alphanumeric_sort::compare_str(e1.name.clone(), e2.name.clone())), SortingMethods::Alpha => { entries.sort_by(|e1, e2| e1.entry_type.partial_cmp(&e2.entry_type).unwrap()); entries.sort_by_key(|e| e.name.clone()) } SortingMethods::DirsFirst => { entries.sort_by_key(|e| e.name.clone()); entries.sort_by(|e1, e2| e1.entry_type.partial_cmp(&e2.entry_type).unwrap()); } }; if reverse_sort { entries.reverse(); } for entry in entries { match entry.entry_type { EntryType::Directory => { let _ = write!( body, "{}/", entry.link, entry.name ); } EntryType::File => { let _ = write!( body, "{}{}", entry.link, entry.name, entry.size.unwrap() ); } } } let html = format!( "\ \ {}\ \ \

{}

\ \ \ \ {}\
NameSize
\n", index_of, index_of, body ); Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html)) }