diff --git a/core/http/src/uri/segments.rs b/core/http/src/uri/segments.rs index 6760c1dc..98f5184b 100644 --- a/core/http/src/uri/segments.rs +++ b/core/http/src/uri/segments.rs @@ -218,6 +218,8 @@ impl<'a> Segments<'a, Path> { for segment in self.clone() { if segment == ".." { buf.pop(); + } else if segment == "." { + continue; } else if !allow_dotfiles && segment.starts_with('.') { return Err(PathError::BadStart('.')) } else if segment.starts_with('*') { diff --git a/core/lib/src/fs/server.rs b/core/lib/src/fs/server.rs index faa95f11..d99999b8 100644 --- a/core/lib/src/fs/server.rs +++ b/core/lib/src/fs/server.rs @@ -1,32 +1,46 @@ -use std::path::{PathBuf, Path}; +use core::fmt; +use std::borrow::Cow; +use std::ffi::OsStr; +use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR}; +use std::sync::Arc; -use crate::{Request, Data}; -use crate::http::{Method, Status, uri::Segments, ext::IntoOwned}; +use crate::fs::NamedFile; +use crate::{Data, Request, outcome::IntoOutcome}; +use crate::http::{ + Method, + HeaderMap, + Header, + uri::Segments, + Status, + ext::IntoOwned, +}; use crate::route::{Route, Handler, Outcome}; use crate::response::{Redirect, Responder}; -use crate::outcome::IntoOutcome; -use crate::fs::NamedFile; /// Custom handler for serving static files. /// -/// This handler makes it simple to serve static files from a directory on the -/// local file system. To use it, construct a `FileServer` using either -/// [`FileServer::from()`] or [`FileServer::new()`] then simply `mount` the -/// handler at a desired path. When mounted, the handler will generate route(s) -/// that serve the desired static files. If a requested file is not found, the -/// routes _forward_ the incoming request. The default rank of the generated -/// routes is `10`. To customize route ranking, use the [`FileServer::rank()`] -/// method. +/// This handler makes is simple to serve static files from a directory on the +/// local file system. To use it, construct a `FileServer` using +/// [`FileServer::from()`], then simply `mount` the handler. When mounted, the +/// handler serves files from the specified directory. If the file is not found, +/// the handler _forwards_ the request. By default, `FileServer` has a rank of +/// `10`. Use [`FileServer::new()`] to create a route with a custom rank. /// -/// # Options +/// # Customization /// -/// The handler's functionality can be customized by passing an [`Options`] to -/// [`FileServer::new()`]. +/// How `FileServer` responds to specific requests can be customized, through +/// the use of [`Rewriter`]s. See [`Rewriter`] for more detailed documentation +/// on how to take full advantage of the customization of `FileServer`. +/// +/// [`FileServer::from()`] and [`FileServer::new()`] automatically add some common +/// rewrites. They filter out dotfiles, redirect folder accesses to include a trailing +/// slash, and use `index.html` to respond to requests for a directory. If you want +/// to customize or replace these default rewrites, see [`FileServer::empty()`]. /// /// # Example /// /// Serve files from the `/static` directory on the local file system at the -/// `/public` path with the [default options](#impl-Default): +/// `/public` path, with the default rewrites. /// /// ```rust,no_run /// # #[macro_use] extern crate rocket; @@ -39,9 +53,8 @@ use crate::fs::NamedFile; /// ``` /// /// Requests for files at `/public/` will be handled by returning the -/// contents of `/static/`. Requests for _directories_ at -/// `/public/` will be handled by returning the contents of -/// `/static//index.html`. +/// contents of `/static/`. Requests for directories will return the +/// contents of `index.html`. /// /// ## Relative Paths /// @@ -60,354 +73,540 @@ use crate::fs::NamedFile; /// rocket::build().mount("/", FileServer::from(relative!("static"))) /// } /// ``` -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct FileServer { - root: PathBuf, - options: Options, + rewrites: Vec>, rank: isize, } +impl fmt::Debug for FileServer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FileServer") + // .field("root", &self.root) + .field("rewrites", &DebugListRewrite(&self.rewrites)) + .field("rank", &self.rank) + .finish() + } +} + +struct DebugListRewrite<'a>(&'a Vec>); + +impl fmt::Debug for DebugListRewrite<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{} rewrites>", self.0.len()) + } +} + +/// Trait used to implement [`FileServer`] customization. +/// +/// Conceptually, a [`FileServer`] is a sequence of `Rewriter`s, which transform +/// a path from a request to a final response. [`FileServer`] add a set of default +/// `Rewriter`s, which filter out dotfiles, apply a root path, normalize directories, +/// and use `index.html`. +/// +/// After running the chain of `Rewriter`s, +/// [`FileServer`] uses the final [`Option`](FileResponse) +/// to respond to the request. If the response is `None`, a path that doesn't +/// exist or a directory path, [`FileServer`] will respond with a +/// [`Status::NotFound`](crate::http::Status::NotFound). Otherwise the [`FileServer`] +/// will respond with a redirect or the contents of the file specified. +/// +/// [`FileServer`] provides several helper methods to add `Rewriter`s: +/// - [`FileServer::and_rewrite()`] +/// - [`FileServer::filter_file()`] +/// - [`FileServer::map_file()`] +pub trait Rewriter: Send + Sync + 'static { + /// Alter the [`FileResponse`] as needed. + fn rewrite<'p, 'h>(&self, path: Option>, req: &Request<'_>) + -> Option>; +} + +/// A Response from a [`FileServer`] +#[derive(Debug)] +#[non_exhaustive] +pub enum FileResponse<'p, 'h> { + /// Return the contents of the specified file. + File(File<'p, 'h>), + /// Returns a Redirect to the specified path. This needs to be an absolute + /// URI, so you should start with [`File.full_uri`](File) when constructing + /// a redirect. + Redirect(Redirect), +} + +impl<'p, 'h> From> for FileResponse<'p, 'h> { + fn from(value: File<'p, 'h>) -> Self { + Self::File(value) + } +} +impl<'p, 'h> From> for Option> { + fn from(value: File<'p, 'h>) -> Self { + Some(FileResponse::File(value)) + } +} + +impl<'p, 'h> From for FileResponse<'p, 'h> { + fn from(value: Redirect) -> Self { + Self::Redirect(value) + } +} +impl<'p, 'h> From for Option> { + fn from(value: Redirect) -> Self { + Some(FileResponse::Redirect(value)) + } +} + +/// A File response from a [`FileServer`] +#[derive(Debug)] +pub struct File<'p, 'h> { + /// The path to the file that [`FileServer`] will respond with. + pub path: Cow<'p, Path>, + /// A list of headers to be added to the generated response. + pub headers: HeaderMap<'h>, +} + +impl<'p, 'h> File<'p, 'h> { + /// Add a header to this `File`. + pub fn with_header<'n: 'h, H: Into>>(mut self, header: H) -> Self { + self.headers.add(header); + self + } + + /// Replace the path of this `File`. + pub fn with_path(self, path: impl Into>) -> Self { + Self { + path: path.into(), + headers: self.headers, + } + } + + /// Replace the path of this `File`. + pub fn map_path>>(self, f: impl FnOnce(Cow<'p, Path>) -> R) -> Self { + Self { + path: f(self.path).into(), + headers: self.headers, + } + } + + // /// Convert this `File` into a Redirect, transforming the URI. + // pub fn into_redirect(self, f: impl FnOnce(Origin<'static>) -> Origin<'static>) + // -> FileResponse<'p, 'h> + // { + // FileResponse::Redirect(Redirect::permanent(f(self.full_uri.clone().into_owned()))) + // } + + async fn respond_to<'r>(self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> + where 'h: 'r + { + /// Normalize paths to enable `file_root` to work properly + fn strip_trailing_slash(p: &Path) -> &Path { + let bytes = p.as_os_str().as_encoded_bytes(); + let bytes = bytes.strip_suffix(MAIN_SEPARATOR_STR.as_bytes()).unwrap_or(bytes); + // SAFETY: Since we stripped a valid UTF-8 sequence (or left it unchanged), + // this is still a valid OsStr. + Path::new(unsafe { OsStr::from_encoded_bytes_unchecked(bytes) }) + } + + let path = strip_trailing_slash(self.path.as_ref()); + // Fun fact, on Linux attempting to open a directory works, it just errors + // when you attempt to read it. + if path.is_file() { + NamedFile::open(path) + .await + .respond_to(req) + .map(|mut r| { + for header in self.headers { + r.adjoin_raw_header(header.name.as_str().to_owned(), header.value); + } + r + }).or_forward((data, Status::NotFound)) + } else { + Outcome::forward(data, Status::NotFound) + } + } +} + +impl Rewriter for F + where F: for<'r, 'h> Fn(Option>, &Request<'_>) + -> Option> +{ + fn rewrite<'p, 'h>(&self, path: Option>, req: &Request<'_>) + -> Option> + { + self(path, req) + } +} + +/// Helper to implement [`FileServer::filter_file()`] +struct FilterFile(F); +impl Rewriter for FilterFile + where F: Fn(&File<'_, '_>, &Request<'_>) -> bool + Send + Sync + 'static +{ + fn rewrite<'p, 'h>(&self, path: Option>, req: &Request<'_>) + -> Option> + { + match path { + Some(FileResponse::File(file)) if !self.0(&file, req) => None, + path => path, + } + } +} + +/// Helper to implement [`FileServer::map_file()`] +struct MapFile(F); +impl Rewriter for MapFile + where F: for<'p, 'h> Fn(File<'p, 'h>, &Request<'_>) + -> FileResponse<'p, 'h> + Send + Sync + 'static, +{ + fn rewrite<'p, 'h>(&self, path: Option>, req: &Request<'_>) + -> Option> + { + match path { + Some(FileResponse::File(file)) => Some(self.0(file, req)), + path => path, + } + } +} + +/// Helper trait to simplify standard rewrites +#[doc(hidden)] +pub trait FileMap: + for<'p, 'h> Fn(File<'p, 'h>, &Request<'_>) -> FileResponse<'p, 'h> + Send + Sync + 'static +{} +impl FileMap for F + where F: for<'p, 'h> Fn(File<'p, 'h>, &Request<'_>) + -> FileResponse<'p, 'h> + Send + Sync + 'static +{} +/// Helper trait to simplify standard rewrites +#[doc(hidden)] +pub trait FileFilter: Fn(&File<'_, '_>, &Request<'_>) -> bool + Send + Sync + 'static {} +impl FileFilter for F + where F: Fn(&File<'_, '_>, &Request<'_>) -> bool + Send + Sync + 'static +{} + +/// Prepends the provided path, to serve files from a directory. +/// +/// You can use [`relative!`] to make a path relative to the crate root, rather +/// than the runtime directory. +/// +/// # Example +/// +/// ```rust,no_run +/// # use rocket::fs::{FileServer, dir_root, relative}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .map_file(dir_root(relative!("static"))) +/// # } +/// ``` +/// +/// # Panics +/// +/// Panics if `path` does not exist. See [`file_root_permissive`] for a +/// non-panicing variant. +pub fn dir_root(path: impl AsRef) -> impl FileMap { + let path = path.as_ref(); + if !path.is_dir() { + let path = path.display(); + error!(%path, "FileServer path is not a directory."); + warn!("Aborting early to prevent inevitable handler error."); + panic!("invalid directory: refusing to continue"); + } + let path = path.to_path_buf(); + move |f, _r| { + FileResponse::File(f.map_path(|p| path.join(p))) + } +} + +/// Prepends the provided path, to serve a single static file. +/// +/// # Example +/// +/// ```rust,no_run +/// # use rocket::fs::{FileServer, file_root}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .map_file(file_root("static/index.html")) +/// # } +/// ``` +/// +/// # Panics +/// +/// Panics if `path` does not exist. See [`file_root_permissive`] for a +/// non-panicing variant. +pub fn file_root(path: impl AsRef) -> impl FileMap { + let path = path.as_ref(); + if !path.exists() { + let path = path.display(); + error!(%path, "FileServer path does not exist."); + warn!("Aborting early to prevent inevitable handler error."); + panic!("invalid file: refusing to continue"); + } + let path = path.to_path_buf(); + move |f, _r| { + FileResponse::File(f.map_path(|p| path.join(p))) + } +} + +/// Prepends the provided path, without checking to ensure the path exists during +/// startup. +/// +/// # Example +/// +/// ```rust,no_run +/// # use rocket::fs::{FileServer, file_root_permissive}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .map_file(file_root_permissive("/tmp/rocket")) +/// # } +/// ``` +pub fn file_root_permissive(path: impl AsRef) -> impl FileMap { + let path = path.as_ref().to_path_buf(); + move |f, _r| { + FileResponse::File(f.map_path(|p| path.join(p))) + } +} + +/// Filters out any path that contains a file or directory name starting with a +/// dot. If used after `dir_root`, this will also check the root path for dots, and +/// filter them. +/// +/// # Example +/// +/// ```rust,no_run +/// # use rocket::fs::{FileServer, filter_dotfiles, dir_root}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .filter_file(filter_dotfiles) +/// .map_file(dir_root("static")) +/// # } +/// ``` +pub fn filter_dotfiles(file: &File<'_, '_>, _req: &Request<'_>) -> bool { + !file.path.iter().any(|s| s.as_encoded_bytes().starts_with(b".")) +} + +/// Normalize directory accesses to always include a trailing slash. +/// +/// Should normally be used after `dir_root` (or another rewrite that adds +/// a root), since it needs the full path to check whether a path points to +/// a directory. +/// +/// # Example +/// +/// Appends a slash to any request for a directory without a trailing slash +/// ```rust,no_run +/// # use rocket::fs::{FileServer, normalize_dirs, dir_root}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .map_file(dir_root("static")) +/// .map_file(normalize_dirs) +/// # } +/// ``` +pub fn normalize_dirs<'p, 'h>(file: File<'p, 'h>, req: &Request<'_>) -> FileResponse<'p, 'h> { + if !req.uri().path().raw().ends_with('/') && file.path.is_dir() { + FileResponse::Redirect(Redirect::permanent( + // Known good path + '/' is a good path + req.uri().clone().into_owned().map_path(|p| format!("{p}/")).unwrap() + )) + } else { + FileResponse::File(file) + } +} + +/// Appends a file name to all directory accesses. +/// +/// Must be used after `dir_root`, since it needs the full path to check whether it is +/// a directory. +/// +/// # Example +/// +/// Appends `index.html` to any directory access. +/// ```rust,no_run +/// # use rocket::fs::{FileServer, index, dir_root}; +/// # fn make_server() -> FileServer { +/// FileServer::empty() +/// .map_file(dir_root("static")) +/// .map_file(index("index.html")) +/// # } +/// ``` +pub fn index(index: &'static str) -> impl FileMap { + move |f, _r| if f.path.is_dir() { + FileResponse::File(f.map_path(|p| p.join(index))) + } else { + FileResponse::File(f) + } +} + impl FileServer { /// The default rank use by `FileServer` routes. const DEFAULT_RANK: isize = 10; - /// Constructs a new `FileServer` that serves files from the file system - /// `path`. By default, [`Options::Index`] is set, and the generated routes - /// have a rank of `10`. To serve static files with other options, use - /// [`FileServer::new()`]. To choose a different rank for generated routes, - /// use [`FileServer::rank()`]. + /// Constructs a new `FileServer`, with default rank, and no + /// rewrites. /// - /// # Panics - /// - /// Panics if `path` does not exist or is not a directory. - /// - /// # Example - /// - /// Serve the static files in the `/www/public` local directory on path - /// `/static`. - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::FileServer; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build().mount("/static", FileServer::from("/www/public")) - /// } - /// ``` - /// - /// Exactly as before, but set the rank for generated routes to `30`. - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::FileServer; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build().mount("/static", FileServer::from("/www/public").rank(30)) - /// } - /// ``` - #[track_caller] - pub fn from>(path: P) -> Self { - FileServer::new(path, Options::default()) + /// See [`FileServer::empty_ranked()`]. + pub fn empty() -> Self { + Self::empty_ranked(Self::DEFAULT_RANK) } - /// Constructs a new `FileServer` that serves files from the file system - /// `path` with `options` enabled. By default, the handler's routes have a - /// rank of `10`. To choose a different rank, use [`FileServer::rank()`]. - /// - /// # Panics - /// - /// If [`Options::Missing`] is not set, panics if `path` does not exist or - /// is not a directory. Otherwise does not panic. + /// Constructs a new `FileServer`, with specified rank, and no + /// rewrites. /// /// # Example /// - /// Serve the static files in the `/www/public` local directory on path - /// `/static` without serving index files or dot files. Additionally, serve - /// the same files on `/pub` with a route rank of -1 while also serving - /// index files and dot files. - /// + /// Replicate the output of [`FileServer::new()`]. /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::{FileServer, Options}; - /// - /// #[launch] - /// fn rocket() -> _ { - /// let options = Options::Index | Options::DotFiles; - /// rocket::build() - /// .mount("/static", FileServer::from("/www/public")) - /// .mount("/pub", FileServer::new("/www/public", options).rank(-1)) - /// } + /// # use rocket::fs::{FileServer, filter_dotfiles, dir_root, normalize_dirs}; + /// # fn launch() -> FileServer { + /// FileServer::empty_ranked(10) + /// .filter_file(filter_dotfiles) + /// .map_file(dir_root("static")) + /// .map_file(normalize_dirs) + /// # } /// ``` - #[track_caller] - pub fn new>(path: P, options: Options) -> Self { - let path = path.as_ref(); - if !options.contains(Options::Missing) { - if !options.contains(Options::IndexFile) && !path.is_dir() { - error!(path = %path.display(), - "FileServer path does not point to a directory.\n\ - Aborting early to prevent inevitable handler runtime errors."); - - panic!("invalid directory path: refusing to continue"); - } else if !path.exists() { - error!(path = %path.display(), - "FileServer path does not point to a file.\n\ - Aborting early to prevent inevitable handler runtime errors."); - - panic!("invalid file path: refusing to continue"); - } + pub fn empty_ranked(rank: isize) -> Self { + Self { + rewrites: vec![], + rank, } - - FileServer { root: path.into(), options, rank: Self::DEFAULT_RANK } } - /// Sets the rank for generated routes to `rank`. + /// Constructs a new `FileServer`, with the defualt rank of 10. + /// + /// See [`FileServer::new`]. + pub fn from>(path: P) -> Self { + Self::new(path, Self::DEFAULT_RANK) + } + + /// Constructs a new `FileServer` that serves files from the file system + /// `path`, with the specified rank. + /// + /// Adds a set of default rewrites: + /// - [`filter_dotfiles`]: Hides all dotfiles. + /// - [`dir_root(path)`](dir_root): Applies the root path. + /// - [`normalize_dirs`]: Normalizes directories to have a trailing slash. + /// - [`index("index.html")`](index): Appends `index.html` to directory requests. + pub fn new>(path: P, rank: isize) -> Self { + Self::empty_ranked(rank) + .filter_file(filter_dotfiles) + .map_file(dir_root(path)) + .map_file(normalize_dirs) + .map_file(index("index.html")) + } + + /// Generic rewrite to transform one FileResponse to another. /// /// # Example /// + /// Redirects all requests that have been filtered to the root of the `FileServer`. /// ```rust,no_run - /// use rocket::fs::{FileServer, Options}; + /// # use rocket::{fs::{FileServer, FileResponse}, response::Redirect, + /// # uri, Build, Rocket, Request}; + /// fn redir_missing<'p, 'h>(p: Option>, _req: &Request<'_>) + /// -> Option> + /// { + /// match p { + /// None => Redirect::temporary(uri!("/")).into(), + /// p => p, + /// } + /// } /// - /// // A `FileServer` created with `from()` with routes of rank `3`. - /// FileServer::from("/public").rank(3); - /// - /// // A `FileServer` created with `new()` with routes of rank `-15`. - /// FileServer::new("/public", Options::Index).rank(-15); + /// # fn launch() -> Rocket { + /// rocket::build() + /// .mount("/", FileServer::from("static").and_rewrite(redir_missing)) + /// # } /// ``` - pub fn rank(mut self, rank: isize) -> Self { - self.rank = rank; + /// + /// Note that `redir_missing` is not a closure in this example. Making it a closure + /// causes compilation to fail with a lifetime error. It really shouldn't but it does. + pub fn and_rewrite(mut self, f: impl Rewriter) -> Self { + self.rewrites.push(Arc::new(f)); self } + + /// Filter what files this `FileServer` will respond with + /// + /// # Example + /// + /// Filter out all paths with a filename of `hidden`. + /// ```rust,no_run + /// # use rocket::{fs::FileServer, response::Redirect, uri, Rocket, Build}; + /// # fn launch() -> Rocket { + /// rocket::build() + /// .mount( + /// "/", + /// FileServer::from("static") + /// .filter_file(|f, _r| f.path.file_name() != Some("hidden".as_ref())) + /// ) + /// # } + /// ``` + pub fn filter_file(self, f: F) -> Self + where F: Fn(&File<'_, '_>, &Request<'_>) -> bool + Send + Sync + 'static + { + self.and_rewrite(FilterFile(f)) + } + + /// Transform files + /// + /// # Example + /// + /// Append `hidden` to the path of every file returned. + /// ```rust,no_run + /// # use rocket::{fs::FileServer, Build, Rocket}; + /// # fn launch() -> Rocket { + /// rocket::build() + /// .mount( + /// "/", + /// FileServer::from("static") + /// .map_file(|f, _r| f.map_path(|p| p.join("hidden")).into()) + /// ) + /// # } + /// ``` + pub fn map_file(self, f: F) -> Self + where F: for<'r, 'h> Fn(File<'r, 'h>, &Request<'_>) + -> FileResponse<'r, 'h> + Send + Sync + 'static + { + self.and_rewrite(MapFile(f)) + } } impl From for Vec { fn from(server: FileServer) -> Self { - let source = figment::Source::File(server.root.clone()); + // let source = figment::Source::File(server.root.clone()); let mut route = Route::ranked(server.rank, Method::Get, "/", server); - route.name = Some(format!("FileServer: {}", source).into()); + // I'd like to provide a more descriptive name, but we can't get more + // information out of `dyn Rewriter` + route.name = Some("FileServer".into()); vec![route] } } + + #[crate::async_trait] impl Handler for FileServer { async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> { - use crate::http::uri::fmt::Path; + use crate::http::uri::fmt::Path as UriPath; + let path: Option = req.segments::>(0..).ok() + .and_then(|segments| segments.to_path_buf(true).ok()); + let mut response = path.as_ref().map(|p| FileResponse::File(File { + path: Cow::Borrowed(p), + headers: HeaderMap::new(), + })); - // TODO: Should we reject dotfiles for `self.root` if !DotFiles? - let options = self.options; - if options.contains(Options::IndexFile) && self.root.is_file() { - let segments = match req.segments::>(0..) { - Ok(segments) => segments, - Err(never) => match never {}, - }; - - if segments.is_empty() { - let file = NamedFile::open(&self.root).await; - return file.respond_to(req).or_forward((data, Status::NotFound)); - } else { - return Outcome::forward(data, Status::NotFound); - } + for rewrite in &self.rewrites { + response = rewrite.rewrite(response, req); } - // Get the segments as a `PathBuf`, allowing dotfiles requested. - let allow_dotfiles = options.contains(Options::DotFiles); - let path = req.segments::>(0..).ok() - .and_then(|segments| segments.to_path_buf(allow_dotfiles).ok()) - .map(|path| self.root.join(path)); - - match path { - Some(p) if p.is_dir() => { - // Normalize '/a/b/foo' to '/a/b/foo/'. - if options.contains(Options::NormalizeDirs) && !req.uri().path().ends_with('/') { - let normal = req.uri().map_path(|p| format!("{}/", p)) - .expect("adding a trailing slash to a known good path => valid path") - .into_owned(); - - return Redirect::permanent(normal) - .respond_to(req) - .or_forward((data, Status::InternalServerError)); - } - - if !options.contains(Options::Index) { - return Outcome::forward(data, Status::NotFound); - } - - let index = NamedFile::open(p.join("index.html")).await; - index.respond_to(req).or_forward((data, Status::NotFound)) + match response { + Some(FileResponse::File(file)) => file.respond_to(req, data).await, + Some(FileResponse::Redirect(r)) => { + r.respond_to(req) + .or_forward((data, Status::InternalServerError)) }, - Some(p) => { - let file = NamedFile::open(p).await; - file.respond_to(req).or_forward((data, Status::NotFound)) - } None => Outcome::forward(data, Status::NotFound), } } } -/// A bitset representing configurable options for [`FileServer`]. -/// -/// The valid options are: -/// -/// * [`Options::None`] - Return only present, visible files. -/// * [`Options::DotFiles`] - In addition to visible files, return dotfiles. -/// * [`Options::Index`] - Render `index.html` pages for directory requests. -/// * [`Options::IndexFile`] - Allow serving a single file as the index. -/// * [`Options::Missing`] - Don't fail if the path to serve is missing. -/// * [`Options::NormalizeDirs`] - Redirect directories without a trailing -/// slash to ones with a trailing slash. -/// -/// `Options` structures can be `or`d together to select two or more options. -/// For instance, to request that both dot files and index pages be returned, -/// use `Options::DotFiles | Options::Index`. -#[derive(Debug, Clone, Copy)] -pub struct Options(u8); - -#[allow(non_upper_case_globals, non_snake_case)] -impl Options { - /// All options disabled. - /// - /// Note that this is different than [`Options::default()`](#impl-Default), - /// which enables options. - pub const None: Options = Options(0); - - /// Respond to requests for a directory with the `index.html` file in that - /// directory, if it exists. - /// - /// When enabled, [`FileServer`] will respond to requests for a directory - /// `/foo` or `/foo/` with the file at `${root}/foo/index.html` if it - /// exists. When disabled, requests to directories will always forward. - /// - /// **Enabled by default.** - pub const Index: Options = Options(1 << 0); - - /// Allow serving dotfiles. - /// - /// When enabled, [`FileServer`] will respond to requests for files or - /// directories beginning with `.`. When disabled, any dotfiles will be - /// treated as missing. - /// - /// **Disabled by default.** - pub const DotFiles: Options = Options(1 << 1); - - /// Normalizes directory requests by redirecting requests to directory paths - /// without a trailing slash to ones with a trailing slash. - /// - /// **Enabled by default.** - /// - /// When enabled, the [`FileServer`] handler will respond to requests for a - /// directory without a trailing `/` with a permanent redirect (308) to the - /// same path with a trailing `/`. This ensures relative URLs within any - /// document served from that directory will be interpreted relative to that - /// directory rather than its parent. - /// - /// # Example - /// - /// Given the following directory structure... - /// - /// ```text - /// static/ - /// └── foo/ - /// ├── cat.jpeg - /// └── index.html - /// ``` - /// - /// And the following server: - /// - /// ```text - /// rocket.mount("/", FileServer::from("static")) - /// ``` - /// - /// ...requests to `example.com/foo` will be redirected to - /// `example.com/foo/`. If `index.html` references `cat.jpeg` as a relative - /// URL, the browser will resolve the URL to `example.com/foo/cat.jpeg`, - /// which in-turn Rocket will match to `/static/foo/cat.jpg`. - /// - /// Without this option, requests to `example.com/foo` would not be - /// redirected. `index.html` would be rendered, and the relative link to - /// `cat.jpeg` would be resolved by the browser as `example.com/cat.jpeg`. - /// Rocket would thus try to find `/static/cat.jpeg`, which does not exist. - pub const NormalizeDirs: Options = Options(1 << 2); - - /// Allow serving a file instead of a directory. - /// - /// By default, `FileServer` will error on construction if the path to serve - /// does not point to a directory. When this option is enabled, if a path to - /// a file is provided, `FileServer` will serve the file as the root of the - /// mount path. - /// - /// # Example - /// - /// If the file tree looks like: - /// - /// ```text - /// static/ - /// └── cat.jpeg - /// ``` - /// - /// Then `cat.jpeg` can be served at `/cat` with: - /// - /// ```rust,no_run - /// # #[macro_use] extern crate rocket; - /// use rocket::fs::{FileServer, Options}; - /// - /// #[launch] - /// fn rocket() -> _ { - /// rocket::build() - /// .mount("/cat", FileServer::new("static/cat.jpeg", Options::IndexFile)) - /// } - /// ``` - pub const IndexFile: Options = Options(1 << 3); - - /// Don't fail if the file or directory to serve is missing. - /// - /// By default, `FileServer` will error if the path to serve is missing to - /// prevent inevitable 404 errors. This option overrides that. - pub const Missing: Options = Options(1 << 4); - - /// Returns `true` if `self` is a superset of `other`. In other words, - /// returns `true` if all of the options in `other` are also in `self`. - /// - /// # Example - /// - /// ```rust - /// use rocket::fs::Options; - /// - /// let index_request = Options::Index | Options::DotFiles; - /// assert!(index_request.contains(Options::Index)); - /// assert!(index_request.contains(Options::DotFiles)); - /// - /// let index_only = Options::Index; - /// assert!(index_only.contains(Options::Index)); - /// assert!(!index_only.contains(Options::DotFiles)); - /// - /// let dot_only = Options::DotFiles; - /// assert!(dot_only.contains(Options::DotFiles)); - /// assert!(!dot_only.contains(Options::Index)); - /// ``` - #[inline] - pub fn contains(self, other: Options) -> bool { - (other.0 & self.0) == other.0 - } -} - -/// The default set of options: `Options::Index | Options:NormalizeDirs`. -impl Default for Options { - fn default() -> Self { - Options::Index | Options::NormalizeDirs - } -} - -impl std::ops::BitOr for Options { - type Output = Self; - - #[inline(always)] - fn bitor(self, rhs: Self) -> Self { - Options(self.0 | rhs.0) - } -} - crate::export! { /// Generates a crate-relative version of a path. /// diff --git a/core/lib/src/response/redirect.rs b/core/lib/src/response/redirect.rs index f685fe1b..6738c45f 100644 --- a/core/lib/src/response/redirect.rs +++ b/core/lib/src/response/redirect.rs @@ -144,6 +144,12 @@ impl Redirect { pub fn moved>>(uri: U) -> Redirect { Redirect(Status::MovedPermanently, uri.try_into().ok()) } + + pub fn map_uri>>(self, f: impl FnOnce(Reference<'static>) -> U) + -> Redirect + { + Redirect(self.0, self.1.and_then(|p| f(p).try_into().ok())) + } } /// Constructs a response with the appropriate status code and the given URL in diff --git a/core/lib/tests/file_server.rs b/core/lib/tests/file_server.rs index c0b33e53..1802d48f 100644 --- a/core/lib/tests/file_server.rs +++ b/core/lib/tests/file_server.rs @@ -4,7 +4,16 @@ use std::path::Path; use rocket::{Rocket, Route, Build}; use rocket::http::Status; use rocket::local::blocking::Client; -use rocket::fs::{FileServer, Options, relative}; +use rocket::fs::{ + dir_root, + file_root, + filter_dotfiles, + index, + file_root_permissive, + normalize_dirs, + relative, + FileServer +}; fn static_root() -> &'static Path { Path::new(relative!("/tests/static")) @@ -14,12 +23,57 @@ fn rocket() -> Rocket { let root = static_root(); rocket::build() .mount("/default", FileServer::from(&root)) - .mount("/no_index", FileServer::new(&root, Options::None)) - .mount("/dots", FileServer::new(&root, Options::DotFiles)) - .mount("/index", FileServer::new(&root, Options::Index)) - .mount("/both", FileServer::new(&root, Options::DotFiles | Options::Index)) - .mount("/redir", FileServer::new(&root, Options::NormalizeDirs)) - .mount("/redir_index", FileServer::new(&root, Options::NormalizeDirs | Options::Index)) + .mount( + "/no_index", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(dir_root(&root)) + ) + .mount( + "/dots", + FileServer::empty() + .map_file(dir_root(&root)) + ) + .mount( + "/index", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(dir_root(&root)) + .map_file(index("index.html")) + ) + .mount( + "/both", + FileServer::empty() + .map_file(dir_root(&root)) + .map_file(index("index.html")) + ) + .mount( + "/redir", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(dir_root(&root)) + .map_file(normalize_dirs) + ) + .mount( + "/redir_index", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(dir_root(&root)) + .map_file(normalize_dirs) + .map_file(index("index.html")) + ) + .mount( + "/index_file", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(file_root(root.join("other/hello.txt"))) + ) + .mount( + "/missing_root", + FileServer::empty() + .filter_file(filter_dotfiles) + .map_file(file_root_permissive(root.join("no_file"))) + ) } static REGULAR_FILES: &[&str] = &[ @@ -39,13 +93,13 @@ static INDEXED_DIRECTORIES: &[&str] = &[ "inner/", ]; -fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { +fn assert_file_matches(client: &Client, prefix: &str, path: &str, disk_path: Option<&str>) { let full_path = format!("/{}/{}", prefix, path); let response = client.get(full_path).dispatch(); - if exists { + if let Some(disk_path) = disk_path { assert_eq!(response.status(), Status::Ok); - let mut path = static_root().join(path); + let mut path = static_root().join(disk_path); if path.is_dir() { path = path.join("index.html"); } @@ -59,6 +113,14 @@ fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { } } +fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { + if exists { + assert_file_matches(client, prefix, path, Some(path)) + } else { + assert_file_matches(client, prefix, path, None) + } +} + fn assert_all(client: &Client, prefix: &str, paths: &[&str], exist: bool) { for path in paths.iter() { assert_file(client, prefix, path, exist); @@ -101,12 +163,28 @@ fn test_static_all() { assert_all(&client, "both", INDEXED_DIRECTORIES, true); } +#[test] +fn test_alt_roots() { + let client = Client::debug(rocket()).expect("valid rocket"); + assert_file(&client, "missing_root", "", false); + assert_file_matches(&client, "index_file", "", Some("other/hello.txt")); +} + +#[test] +fn test_allow_special_dotpaths() { + let client = Client::debug(rocket()).expect("valid rocket"); + assert_file_matches(&client, "no_index", "./index.html", Some("index.html")); + assert_file_matches(&client, "no_index", "foo/../index.html", Some("index.html")); + assert_file_matches(&client, "no_index", "inner/./index.html", Some("inner/index.html")); + assert_file_matches(&client, "no_index", "../index.html", Some("index.html")); +} + #[test] fn test_ranking() { let root = static_root(); for rank in -128..128 { - let a = FileServer::new(&root, Options::None).rank(rank); - let b = FileServer::from(&root).rank(rank); + let a = FileServer::new(&root, rank); + let b = FileServer::new(&root, rank); for handler in vec![a, b] { let routes: Vec = handler.into(); @@ -190,3 +268,21 @@ fn test_redirection() { assert_eq!(response.status(), Status::PermanentRedirect); assert_eq!(response.headers().get("Location").next(), Some("/redir_index/other/")); } + +#[test] +#[should_panic] +fn test_panic_on_missing_file() { + let _ = file_root(static_root().join("missing_file")); +} + +#[test] +#[should_panic] +fn test_panic_on_missing_dir() { + let _ = dir_root(static_root().join("missing_dir")); +} + +#[test] +#[should_panic] +fn test_panic_on_file_not_dir() { + let _ = dir_root(static_root().join("index.html")); +}