diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/archive.rs | 24 | ||||
-rw-r--r-- | src/args.rs | 16 | ||||
-rw-r--r-- | src/config.rs | 37 | ||||
-rw-r--r-- | src/errors.rs | 3 | ||||
-rw-r--r-- | src/file_op.rs | 18 | ||||
-rw-r--r-- | src/listing.rs | 39 | ||||
-rw-r--r-- | src/main.rs | 56 | ||||
-rw-r--r-- | src/pipe.rs | 2 | ||||
-rw-r--r-- | src/renderer.rs | 20 | ||||
-rw-r--r-- | src/webdav_fs.rs | 83 |
10 files changed, 226 insertions, 72 deletions
diff --git a/src/archive.rs b/src/archive.rs index b8ba4d4..da79ef8 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -28,27 +28,27 @@ pub enum ArchiveMethod { impl ArchiveMethod { pub fn extension(self) -> String { match self { - ArchiveMethod::TarGz => "tar.gz", - ArchiveMethod::Tar => "tar", - ArchiveMethod::Zip => "zip", + Self::TarGz => "tar.gz", + Self::Tar => "tar", + Self::Zip => "zip", } .to_string() } pub fn content_type(self) -> String { match self { - ArchiveMethod::TarGz => "application/gzip", - ArchiveMethod::Tar => "application/tar", - ArchiveMethod::Zip => "application/zip", + Self::TarGz => "application/gzip", + Self::Tar => "application/tar", + Self::Zip => "application/zip", } .to_string() } pub fn is_enabled(self, tar_enabled: bool, tar_gz_enabled: bool, zip_enabled: bool) -> bool { match self { - ArchiveMethod::TarGz => tar_gz_enabled, - ArchiveMethod::Tar => tar_enabled, - ArchiveMethod::Zip => zip_enabled, + Self::TarGz => tar_gz_enabled, + Self::Tar => tar_enabled, + Self::Zip => zip_enabled, } } @@ -69,9 +69,9 @@ impl ArchiveMethod { { let dir = dir.as_ref(); match self { - ArchiveMethod::TarGz => tar_gz(dir, skip_symlinks, out), - ArchiveMethod::Tar => tar_dir(dir, skip_symlinks, out), - ArchiveMethod::Zip => zip_dir(dir, skip_symlinks, out), + Self::TarGz => tar_gz(dir, skip_symlinks, out), + Self::Tar => tar_dir(dir, skip_symlinks, out), + Self::Zip => zip_dir(dir, skip_symlinks, out), } } } diff --git a/src/args.rs b/src/args.rs index 70ad208..f117b1c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,8 +1,8 @@ use std::net::IpAddr; use std::path::PathBuf; +use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue}; use clap::{Parser, ValueEnum, ValueHint}; -use http::header::{HeaderMap, HeaderName, HeaderValue}; use crate::auth; use crate::listing::{SortingMethod, SortingOrder}; @@ -162,7 +162,7 @@ pub struct CliArgs { /// The provided path is not a physical file system path. Instead, it's relative to the serve /// dir. For instance, if the serve dir is '/home/hello', set this to '/upload' to allow /// uploading to '/home/hello/upload'. - /// When specified via environment variable, a path always needs to the specified. + /// When specified via environment variable, a path always needs to be specified. #[arg(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_UPLOAD_DIR")] pub allowed_upload_dir: Option<Vec<PathBuf>>, @@ -203,7 +203,11 @@ pub struct CliArgs { pub media_type_raw: Option<String>, /// Enable overriding existing files during file upload - #[arg(short = 'o', long = "overwrite-files", env = "OVERWRITE_FILES")] + #[arg( + short = 'o', + long = "overwrite-files", + env = "MINISERVE_OVERWRITE_FILES" + )] pub overwrite_files: bool, /// Enable uncompressed tar archive generation @@ -313,6 +317,12 @@ pub struct CliArgs { /// and return an error instead. #[arg(short = 'I', long, env = "MINISERVE_DISABLE_INDEXING")] pub disable_indexing: bool, + + /// Enable read-only WebDAV support (PROPFIND requests) + /// + /// Currently incompatible with -P|--no-symlinks (see https://github.com/messense/dav-server-rs/issues/37) + #[arg(long, env = "MINISERVE_ENABLE_WEBDAV", conflicts_with = "no_symlinks")] + pub enable_webdav: bool, } /// Checks whether an interface is valid, i.e. it can be parsed into an IP address diff --git a/src/config.rs b/src/config.rs index 6e8b89e..449d2ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,8 +5,8 @@ use std::{ path::PathBuf, }; +use actix_web::http::header::HeaderMap; use anyhow::{anyhow, Context, Result}; -use http::HeaderMap; #[cfg(feature = "tls")] use rustls_pemfile as pemfile; @@ -152,6 +152,9 @@ pub struct MiniserveConfig { /// If enabled, indexing is disabled. pub disable_indexing: bool, + /// If enabled, respond to WebDAV requests (read-only). + pub webdav_enabled: bool, + /// If set, use provided rustls config for TLS #[cfg(feature = "tls")] pub tls_rustls_config: Option<rustls::ServerConfig>, @@ -196,13 +199,13 @@ impl MiniserveConfig { // Otherwise, we should apply route_prefix to static files. let (favicon_route, css_route) = if args.random_route { ( - format!("/{}", nanoid::nanoid!(10, &ROUTE_ALPHABET)), - format!("/{}", nanoid::nanoid!(10, &ROUTE_ALPHABET)), + "/__miniserve_internal/favicon.svg".into(), + "/__miniserve_internal/style.css".into(), ) } else { ( - format!("{}/{}", route_prefix, nanoid::nanoid!(10, &ROUTE_ALPHABET)), - format!("{}/{}", route_prefix, nanoid::nanoid!(10, &ROUTE_ALPHABET)), + format!("{}/{}", route_prefix, "__miniserve_internal/favicon.ico"), + format!("{}/{}", route_prefix, "__miniserve_internal/style.css"), ) }; @@ -226,24 +229,15 @@ impl MiniserveConfig { let key_file = &mut BufReader::new( File::open(&tls_key).context(format!("Couldn't access TLS key {tls_key:?}"))?, ); - let cert_chain = pemfile::certs(cert_file).context("Reading cert file")?; - let key = pemfile::read_all(key_file) + let cert_chain = pemfile::certs(cert_file) + .map(|cert| cert.expect("Invalid certificate in certificate chain")) + .collect(); + let private_key = pemfile::private_key(key_file) .context("Reading private key file")? - .into_iter() - .find_map(|item| match item { - pemfile::Item::RSAKey(key) - | pemfile::Item::PKCS8Key(key) - | pemfile::Item::ECKey(key) => Some(key), - _ => None, - }) - .ok_or_else(|| anyhow!("No supported private key in file"))?; + .expect("No private key found"); let server_config = rustls::ServerConfig::builder() - .with_safe_defaults() .with_no_client_auth() - .with_single_cert( - cert_chain.into_iter().map(rustls::Certificate).collect(), - rustls::PrivateKey(key), - )?; + .with_single_cert(cert_chain, private_key)?; Some(server_config) } else { None @@ -281,7 +275,7 @@ impl MiniserveConfig { .transpose()? .unwrap_or_default(); - Ok(MiniserveConfig { + Ok(Self { verbose: args.verbose, path: args.path.unwrap_or_else(|| PathBuf::from(".")), port, @@ -319,6 +313,7 @@ impl MiniserveConfig { show_wget_footer: args.show_wget_footer, readme: args.readme, disable_indexing: args.disable_indexing, + webdav_enabled: args.enable_webdav, tls_rustls_config: tls_rustls_server_config, compress_response: args.compress_response, }) diff --git a/src/errors.rs b/src/errors.rs index 600834b..f0e22ab 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -24,6 +24,9 @@ Please set an explicit serve path like: `miniserve /my/path`")] /// In case miniserve was invoked with --no-symlinks but the serve path is a symlink #[error("The -P|--no-symlinks option was provided but the serve path '{0}' is a symlink")] NoSymlinksOptionWithSymlinkServePath(String), + + #[error("The --enable-webdav option was provided, but the serve path '{0}' is a file")] + WebdavWithFileServePath(String), } #[derive(Debug, Error)] diff --git a/src/file_op.rs b/src/file_op.rs index 9f5902c..76a7234 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -61,7 +61,7 @@ async fn handle_multipart( allow_hidden_paths: bool, allow_symlinks: bool, ) -> Result<u64, RuntimeError> { - let field_name = field.name().to_string(); + let field_name = field.name().expect("No name field found").to_string(); match tokio::fs::metadata(&path).await { Err(_) => Err(RuntimeError::InsufficientPermissionsError( @@ -143,12 +143,16 @@ async fn handle_multipart( }; } - let filename = field.content_disposition().get_filename().ok_or_else(|| { - RuntimeError::ParseError( - "HTTP header".to_string(), - "Failed to retrieve the name of the file to upload".to_string(), - ) - })?; + let filename = field + .content_disposition() + .expect("No content-disposition field found") + .get_filename() + .ok_or_else(|| { + RuntimeError::ParseError( + "HTTP header".to_string(), + "Failed to retrieve the name of the file to upload".to_string(), + ) + })?; let filename_path = sanitize_path(Path::new(&filename), allow_hidden_paths) .ok_or_else(|| RuntimeError::InvalidPathError("Invalid file name to upload".to_string()))?; diff --git a/src/listing.rs b/src/listing.rs index e24b41c..d908e23 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -3,7 +3,9 @@ use std::io; use std::path::{Component, Path}; use std::time::SystemTime; -use actix_web::{dev::ServiceResponse, web::Query, HttpMessage, HttpRequest, HttpResponse}; +use actix_web::{ + dev::ServiceResponse, http::Uri, web::Query, HttpMessage, HttpRequest, HttpResponse, +}; use bytesize::ByteSize; use clap::ValueEnum; use comrak::{markdown_to_html, ComrakOptions}; @@ -17,16 +19,26 @@ use crate::auth::CurrentUser; use crate::errors::{self, RuntimeError}; use crate::renderer; -use self::percent_encode_sets::PATH_SEGMENT; +use self::percent_encode_sets::COMPONENT; /// "percent-encode sets" as defined by WHATWG specs: /// https://url.spec.whatwg.org/#percent-encoded-bytes mod percent_encode_sets { use percent_encoding::{AsciiSet, CONTROLS}; - const BASE: &AsciiSet = &CONTROLS.add(b'%'); - pub const QUERY: &AsciiSet = &BASE.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>'); + pub const QUERY: &AsciiSet = &CONTROLS.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'/').add(b'\\'); + pub const USERINFO: &AsciiSet = &PATH + .add(b'/') + .add(b':') + .add(b';') + .add(b'=') + .add(b'@') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'|'); + pub const COMPONENT: &AsciiSet = &USERINFO.add(b'$').add(b'%').add(b'&').add(b'+').add(b','); } /// Query parameters used by listing APIs @@ -109,7 +121,7 @@ impl Entry { last_modification_date: Option<SystemTime>, symlink_info: Option<String>, ) -> Self { - Entry { + Self { name, entry_type, link, @@ -141,7 +153,7 @@ pub struct Breadcrumb { impl Breadcrumb { fn new(name: String, link: String) -> Self { - Breadcrumb { name, link } + Self { name, link } } } @@ -173,7 +185,7 @@ pub fn directory_listing( let base = Path::new(serve_path); let random_route_abs = format!("/{}", conf.route_prefix); let abs_uri = { - let res = http::Uri::builder() + let res = Uri::builder() .scheme(req.connection_info().scheme()) .authority(req.connection_info().host()) .path_and_query(req.uri().to_string()) @@ -214,7 +226,7 @@ pub fn directory_listing( Component::Normal(s) => { name = s.to_string_lossy().to_string(); link_accumulator - .push_str(&(utf8_percent_encode(&name, PATH_SEGMENT).to_string() + "/")); + .push_str(&(utf8_percent_encode(&name, COMPONENT).to_string() + "/")); } _ => name = "".to_string(), }; @@ -253,7 +265,7 @@ pub fn directory_listing( .and_then(|path| std::fs::read_link(path).ok()) .map(|path| path.to_string_lossy().into_owned()); let file_url = base - .join(utf8_percent_encode(&file_name, PATH_SEGMENT).to_string()) + .join(utf8_percent_encode(&file_name, COMPONENT).to_string()) .to_string_lossy() .to_string(); @@ -262,10 +274,7 @@ pub fn directory_listing( if conf.no_symlinks && is_symlink { continue; } - let last_modification_date = match metadata.modified() { - Ok(date) => Some(date), - Err(_) => None, - }; + let last_modification_date = metadata.modified().ok(); if metadata.is_dir() { entries.push(Entry::new( @@ -286,7 +295,7 @@ pub fn directory_listing( symlink_dest, )); if conf.readme && readme_rx.is_match(&file_name.to_lowercase()) { - let ext = file_name.split('.').last().unwrap().to_lowercase(); + let ext = file_name.split('.').next_back().unwrap().to_lowercase(); readme = Some(( file_name.to_string(), if ext == "md" { diff --git a/src/main.rs b/src/main.rs index aa40585..ccf611c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,18 @@ use std::time::Duration; use actix_files::NamedFile; use actix_web::{ dev::{fn_service, ServiceRequest, ServiceResponse}, - http::header::ContentType, + guard, + http::{header::ContentType, Method}, middleware, web, App, HttpRequest, HttpResponse, Responder, }; use actix_web_httpauth::middleware::HttpAuthentication; use anyhow::Result; use clap::{crate_version, CommandFactory, Parser}; use colored::*; +use dav_server::{ + actix::{DavRequest, DavResponse}, + DavConfig, DavHandler, DavMethodSet, +}; use fast_qr::QRBuilder; use log::{error, warn}; @@ -27,9 +32,11 @@ mod file_utils; mod listing; mod pipe; mod renderer; +mod webdav_fs; use crate::config::MiniserveConfig; use crate::errors::{RuntimeError, StartupError}; +use crate::webdav_fs::RestrictedFs; static STYLESHEET: &str = grass::include!("data/style.scss"); @@ -52,9 +59,8 @@ fn main() -> Result<()> { let miniserve_config = MiniserveConfig::try_from_args(args)?; - run(miniserve_config).map_err(|e| { + run(miniserve_config).inspect_err(|e| { errors::log_error_chain(e.to_string()); - e })?; Ok(()) @@ -89,6 +95,12 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { )); } + if miniserve_config.webdav_enabled && miniserve_config.path.is_file() { + return Err(StartupError::WebdavWithFileServePath( + miniserve_config.path.to_string_lossy().to_string(), + )); + } + let inside_config = miniserve_config.clone(); let canon_path = miniserve_config @@ -228,7 +240,7 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { #[cfg(feature = "tls")] let srv = match &miniserve_config.tls_rustls_config { - Some(tls_config) => srv.listen_rustls(listener, tls_config.clone()), + Some(tls_config) => srv.listen_rustls_0_23(listener, tls_config.clone()), None => srv.listen(listener), }; @@ -308,7 +320,9 @@ fn configure_header(conf: &MiniserveConfig) -> middleware::DefaultHeaders { /// This is where we configure the app to serve an index file, the file listing, or a single file. fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { let dir_service = || { - let mut files = actix_files::Files::new("", &conf.path); + // use routing guard so propfind and options requests fall through to the webdav handler + let mut files = actix_files::Files::new("", &conf.path) + .guard(guard::Any(guard::Get()).or(guard::Head())); // Use specific index file if one was provided. if let Some(ref index_file) = conf.index { @@ -377,6 +391,38 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { // Handle directories app.service(dir_service()); } + + if conf.webdav_enabled { + let fs = RestrictedFs::new(&conf.path, conf.show_hidden); + + let dav_server = DavHandler::builder() + .filesystem(fs) + .methods(DavMethodSet::WEBDAV_RO) + .hide_symlinks(conf.no_symlinks) + .strip_prefix(conf.route_prefix.to_owned()) + .build_handler(); + + app.app_data(web::Data::new(dav_server.clone())); + + app.service( + // actix requires tail segment to be named, even if unused + web::resource("/{tail}*") + .guard( + guard::Any(guard::Options()) + .or(guard::Method(Method::from_bytes(b"PROPFIND").unwrap())), + ) + .to(dav_handler), + ); + } +} + +async fn dav_handler(req: DavRequest, davhandler: web::Data<DavHandler>) -> DavResponse { + if let Some(prefix) = req.prefix() { + let config = DavConfig::new().strip_prefix(prefix); + davhandler.handle_with(config, req.request).await.into() + } else { + davhandler.handle(req.request).await.into() + } } async fn error_404(req: HttpRequest) -> Result<HttpResponse, RuntimeError> { diff --git a/src/pipe.rs b/src/pipe.rs index 51f094a..45ada8b 100644 --- a/src/pipe.rs +++ b/src/pipe.rs @@ -17,7 +17,7 @@ pub struct Pipe { impl Pipe { /// Wrap the given sender in a `Pipe`. pub fn new(destination: Sender<io::Result<Bytes>>) -> Self { - Pipe { + Self { dest: destination, bytes: BytesMut::new(), } diff --git a/src/renderer.rs b/src/renderer.rs index 9af601c..035309d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,6 +1,6 @@ use std::time::SystemTime; -use actix_web::http::StatusCode; +use actix_web::http::{StatusCode, Uri}; use chrono::{DateTime, Local}; use chrono_humanize::Humanize; use clap::{crate_name, crate_version, ValueEnum}; @@ -9,7 +9,6 @@ use fast_qr::{ qr::QRCodeError, QRBuilder, }; -use http::Uri; use maud::{html, Markup, PreEscaped, DOCTYPE}; use strum::{Display, IntoEnumIterator}; @@ -118,7 +117,7 @@ pub fn page( } } } - @if conf.mkdir_enabled { + @if conf.mkdir_enabled && upload_allowed { div.toolbar_box { form id="mkdir" action=(mkdir_action) method="POST" enctype="multipart/form-data" { p { "Specify a directory name to create" } @@ -358,10 +357,10 @@ pub enum ThemeSlug { impl ThemeSlug { pub fn css(&self) -> &str { match self { - ThemeSlug::Squirrel => grass::include!("data/themes/squirrel.scss"), - ThemeSlug::Archlinux => grass::include!("data/themes/archlinux.scss"), - ThemeSlug::Zenburn => grass::include!("data/themes/zenburn.scss"), - ThemeSlug::Monokai => grass::include!("data/themes/monokai.scss"), + Self::Squirrel => grass::include!("data/themes/squirrel.scss"), + Self::Archlinux => grass::include!("data/themes/archlinux.scss"), + Self::Zenburn => grass::include!("data/themes/zenburn.scss"), + Self::Monokai => grass::include!("data/themes/monokai.scss"), } } @@ -553,7 +552,12 @@ fn entry_row( @if !raw { @if let Some(size) = entry.size { span.mobile-info.size { - (maud::display(size)) + (build_link("size", &format!("{}", size), sort_method, sort_order)) + } + } + @if let Some(modification_timer) = humanize_systemtime(entry.last_modification_date) { + span.mobile-info.history { + (build_link("date", &modification_timer, sort_method, sort_order)) } } } diff --git a/src/webdav_fs.rs b/src/webdav_fs.rs new file mode 100644 index 0000000..cf434ba --- /dev/null +++ b/src/webdav_fs.rs @@ -0,0 +1,83 @@ +//! Helper types and functions to allow configuring hidden files visibility +//! for WebDAV handlers + +use dav_server::{davpath::DavPath, fs::*, localfs::LocalFs}; +use futures::{future::ready, StreamExt, TryFutureExt}; +use std::path::{Component, Path}; + +/// A dav_server local filesystem backend that can be configured to deny access +/// to files and directories with names starting with a dot. +#[derive(Clone)] +pub struct RestrictedFs { + local: Box<LocalFs>, + show_hidden: bool, +} + +impl RestrictedFs { + /// Creates a new RestrictedFs serving the local path at "base". + /// If "show_hidden" is false, access to hidden files is prevented. + pub fn new<P: AsRef<Path>>(base: P, show_hidden: bool) -> Box<RestrictedFs> { + let local = LocalFs::new(base, false, false, false); + Box::new(RestrictedFs { local, show_hidden }) + } +} + +/// true if any normal component of path either starts with dot or can't be turned into a str +fn path_has_hidden_components(path: &DavPath) -> bool { + path.as_pathbuf().components().any(|c| match c { + Component::Normal(name) => name.to_str().is_none_or(|s| s.starts_with('.')), + _ => false, + }) +} + +impl DavFileSystem for RestrictedFs { + fn open<'a>( + &'a self, + path: &'a DavPath, + options: OpenOptions, + ) -> FsFuture<'a, Box<dyn DavFile>> { + if !path_has_hidden_components(path) || self.show_hidden { + self.local.open(path, options) + } else { + Box::pin(ready(Err(FsError::NotFound))) + } + } + + fn read_dir<'a>( + &'a self, + path: &'a DavPath, + meta: ReadDirMeta, + ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { + if self.show_hidden { + self.local.read_dir(path, meta) + } else if !path_has_hidden_components(path) { + Box::pin(self.local.read_dir(path, meta).map_ok(|stream| { + let dyn_stream: FsStream<Box<dyn DavDirEntry>> = Box::pin(stream.filter(|entry| { + ready(match entry { + Ok(ref e) => !e.name().starts_with(b"."), + _ => false, + }) + })); + dyn_stream + })) + } else { + Box::pin(ready(Err(FsError::NotFound))) + } + } + + fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { + if !path_has_hidden_components(path) || self.show_hidden { + self.local.metadata(path) + } else { + Box::pin(ready(Err(FsError::NotFound))) + } + } + + fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { + if !path_has_hidden_components(path) || self.show_hidden { + self.local.symlink_metadata(path) + } else { + Box::pin(ready(Err(FsError::NotFound))) + } + } +} |