From ec130f96ee549dc391e0db9cd2509a67328783ac Mon Sep 17 00:00:00 2001 From: Sergio Benitez Date: Mon, 13 Aug 2018 02:13:55 -0700 Subject: [PATCH] Implement a 'StaticFiles' custom handler in contrib. Closes #239. --- contrib/lib/Cargo.toml | 3 +- contrib/lib/src/lib.rs | 4 + contrib/lib/src/static_files.rs | 241 ++++++++++++++++++++++ contrib/lib/tests/static/.hidden | 1 + contrib/lib/tests/static/index.html | 1 + contrib/lib/tests/static/inner/.hideme | 1 + contrib/lib/tests/static/inner/goodbye | 1 + contrib/lib/tests/static/inner/index.html | 1 + contrib/lib/tests/static/other/hello.txt | 1 + contrib/lib/tests/static_files.rs | 109 ++++++++++ examples/static_files/Cargo.toml | 1 + examples/static_files/src/main.rs | 21 +- examples/static_files/src/tests.rs | 2 +- 13 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 contrib/lib/src/static_files.rs create mode 100644 contrib/lib/tests/static/.hidden create mode 100644 contrib/lib/tests/static/index.html create mode 100644 contrib/lib/tests/static/inner/.hideme create mode 100644 contrib/lib/tests/static/inner/goodbye create mode 100644 contrib/lib/tests/static/inner/index.html create mode 100644 contrib/lib/tests/static/other/hello.txt create mode 100644 contrib/lib/tests/static_files.rs diff --git a/contrib/lib/Cargo.toml b/contrib/lib/Cargo.toml index 24cbd404..26106fac 100644 --- a/contrib/lib/Cargo.toml +++ b/contrib/lib/Cargo.toml @@ -11,11 +11,12 @@ keywords = ["rocket", "web", "framework", "contrib", "contributed"] license = "MIT/Apache-2.0" [features] -default = ["json"] +default = ["json", "static_files"] json = ["serde", "serde_json"] msgpack = ["serde", "rmp-serde"] tera_templates = ["tera", "templates"] handlebars_templates = ["handlebars", "templates"] +static_files = [] # Internal use only. templates = ["serde", "serde_json", "glob"] diff --git a/contrib/lib/src/lib.rs b/contrib/lib/src/lib.rs index 3dbc2d41..839f5cc6 100644 --- a/contrib/lib/src/lib.rs +++ b/contrib/lib/src/lib.rs @@ -18,6 +18,7 @@ //! an asterisk next to the features that are enabled by default: //! //! * [json*](struct.Json.html) +//! * [static_files*](static_files) //! * [msgpack](struct.MsgPack.html) //! * [handlebars_templates](struct.Template.html) //! * [tera_templates](struct.Template.html) @@ -82,3 +83,6 @@ mod uuid; #[cfg(feature = "uuid")] pub use uuid::{Uuid, UuidParseError}; + +#[cfg(feature = "static_files")] +pub mod static_files; diff --git a/contrib/lib/src/static_files.rs b/contrib/lib/src/static_files.rs new file mode 100644 index 00000000..15c005f3 --- /dev/null +++ b/contrib/lib/src/static_files.rs @@ -0,0 +1,241 @@ +//! Custom handler and options for static file serving. + +use std::path::{PathBuf, Path}; + +use rocket::{Request, Data, Route}; +use rocket::http::{Method, Status, uri::Segments}; +use rocket::handler::{Handler, Outcome}; +use rocket::response::NamedFile; +use rocket::outcome::IntoOutcome; + +/// A bitset representing configurable options for the [`StaticFiles`] handler. +/// +/// 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. +/// +/// Two `Options` structures can be `or`d together to slect 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)] +impl Options { + /// `Options` representing the empty set. No dotfiles or index pages are + /// rendered. This is different than the _default_, which enables `Index`. + pub const None: Options = Options(0b0000); + + /// `Options` enabling responding to requests for a directory with the + /// `index.html` file in that directory, if it exists. When this is enabled, + /// the [`StaticFiles`] handler will respond to requests for a directory + /// `/foo` with the file `${root}/foo/index.html` if it exists. This is + /// enabled by default. + pub const Index: Options = Options(0b0001); + + /// `Options` enabling returning dot files. When this is enabled, the + /// [`StaticFiles`] handler will respond to requests for files or + /// directories beginning with `.`. This is _not_ enabled by default. + pub const DotFiles: Options = Options(0b0010); + + /// 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_contrib::static_files::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 + } +} + +impl ::std::ops::BitOr for Options { + type Output = Self; + + #[inline(always)] + fn bitor(self, rhs: Self) -> Self { + Options(self.0 | rhs.0) + } +} + +/// 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 `StaticFiles` using either +/// [`StaticFiles::from()`] or [`StaticFiles::new()`] then simply `mount` the +/// handler at a desired path. +/// +/// # Options +/// +/// The handler's functionality can be customized by passing an [`Options`] to +/// [`StaticFiles::new()`]. +/// +/// # Example +/// +/// To serve files from this directory at the `/public` path, allowing +/// `index.html` files to be used to respond to requests for a directory (the +/// default), you might write the following: +/// +/// ```rust +/// # extern crate rocket; +/// # extern crate rocket_contrib; +/// use rocket_contrib::static_files::StaticFiles; +/// +/// fn main() { +/// # if false { +/// rocket::ignite() +/// .mount("/public", StaticFiles::from("/static")) +/// .launch(); +/// # } +/// } +/// ``` +/// +/// With this set-up, 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`. +/// +/// If your static files are stored relative to your crate and your project is +/// managed by Cargo, you should either use a relative path and ensure that your +/// server is started in the crate's root directory or use the +/// `CARGO_MANIFEST_DIR` to create an absolute path relative to your crate root. +/// For example, to serve files in the `static` subdirectory of your crate at +/// `/`, you might write: +/// +/// ```rust +/// # extern crate rocket; +/// # extern crate rocket_contrib; +/// use rocket_contrib::static_files::StaticFiles; +/// +/// fn main() { +/// # if false { +/// rocket::ignite() +/// .mount("/", StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static"))) +/// .launch(); +/// # } +/// } +/// ``` +#[derive(Clone)] +pub struct StaticFiles { + root: PathBuf, + options: Options, +} + +impl StaticFiles { + /// Constructs a new `StaticFiles` that serves files from the file system + /// `path`. By default, [`Options::Index`] is enabled. To serve static files + /// with other options, use [`StaticFiles::new()`]. + /// + /// # Example + /// + /// Serve the static files in the `/www/public` local directory on path + /// `/static`. + /// + /// ```rust + /// # extern crate rocket; + /// # extern crate rocket_contrib; + /// use rocket_contrib::static_files::StaticFiles; + /// + /// fn main() { + /// # if false { + /// rocket::ignite() + /// .mount("/static", StaticFiles::from("/www/public")) + /// .launch(); + /// # } + /// } + /// ``` + pub fn from>(path: P) -> Self { + StaticFiles::new(path, Options::Index) + } + + /// Constructs a new `StaticFiles` that serves files from the file system + /// `path` with `options` enabled. + /// + /// # 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` while also seriving index files and dot files. + /// + /// ```rust + /// # extern crate rocket; + /// # extern crate rocket_contrib; + /// use rocket_contrib::static_files::{StaticFiles, Options}; + /// + /// fn main() { + /// # if false { + /// let options = Options::Index | Options::DotFiles; + /// rocket::ignite() + /// .mount("/static", StaticFiles::from("/www/public")) + /// .mount("/pub", StaticFiles::new("/www/public", options)) + /// .launch(); + /// # } + /// } + /// ``` + pub fn new>(path: P, options: Options) -> Self { + StaticFiles { root: path.as_ref().into(), options } + } +} + +impl Into> for StaticFiles { + fn into(self) -> Vec { + let non_index = Route::ranked(10, Method::Get, "/", self.clone()); + if self.options.contains(Options::Index) { + let index = Route::ranked(10, Method::Get, "/", self); + vec![index, non_index] + } else { + vec![non_index] + } + } +} + +impl Handler for StaticFiles { + fn handle<'r>(&self, req: &'r Request, _: Data) -> Outcome<'r> { + fn handle_index<'r>(opt: Options, r: &'r Request, path: &Path) -> Outcome<'r> { + if !opt.contains(Options::Index) { + return Outcome::failure(Status::NotFound); + } + + Outcome::from(r, NamedFile::open(path.join("index.html")).ok()) + } + + // If this is not the route with segments, handle it only if the user + // requested a handling of index files. + let current_route = req.route().expect("route while handling"); + let is_segments_route = current_route.uri.path().ends_with(">"); + if !is_segments_route { + return handle_index(self.options, req, &self.root); + } + + // Otherwise, we're handling segments. Get the segments as a `PathBuf`, + // only allowing dotfiles if the user allowed it. + let allow_dotfiles = self.options.contains(Options::DotFiles); + let path = req.get_segments::(0).ok() + .and_then(|segments| segments.into_path_buf(allow_dotfiles).ok()) + .map(|path| self.root.join(path)) + .into_outcome(Status::NotFound)?; + + if path.is_dir() { + handle_index(self.options, req, &path) + } else { + Outcome::from(req, NamedFile::open(&path).ok()) + } + } +} diff --git a/contrib/lib/tests/static/.hidden b/contrib/lib/tests/static/.hidden new file mode 100644 index 00000000..a84ea2ed --- /dev/null +++ b/contrib/lib/tests/static/.hidden @@ -0,0 +1 @@ +Peek-a-boo. diff --git a/contrib/lib/tests/static/index.html b/contrib/lib/tests/static/index.html new file mode 100644 index 00000000..5f426b6f --- /dev/null +++ b/contrib/lib/tests/static/index.html @@ -0,0 +1 @@ +Just a file here: index.html. diff --git a/contrib/lib/tests/static/inner/.hideme b/contrib/lib/tests/static/inner/.hideme new file mode 100644 index 00000000..0c624bef --- /dev/null +++ b/contrib/lib/tests/static/inner/.hideme @@ -0,0 +1 @@ +Oh no! diff --git a/contrib/lib/tests/static/inner/goodbye b/contrib/lib/tests/static/inner/goodbye new file mode 100644 index 00000000..0fd7aa4e --- /dev/null +++ b/contrib/lib/tests/static/inner/goodbye @@ -0,0 +1 @@ +Thanks for coming! diff --git a/contrib/lib/tests/static/inner/index.html b/contrib/lib/tests/static/inner/index.html new file mode 100644 index 00000000..1810989f --- /dev/null +++ b/contrib/lib/tests/static/inner/index.html @@ -0,0 +1 @@ +Inner index. diff --git a/contrib/lib/tests/static/other/hello.txt b/contrib/lib/tests/static/other/hello.txt new file mode 100644 index 00000000..663adb09 --- /dev/null +++ b/contrib/lib/tests/static/other/hello.txt @@ -0,0 +1 @@ +Hi! diff --git a/contrib/lib/tests/static_files.rs b/contrib/lib/tests/static_files.rs new file mode 100644 index 00000000..d9e5a289 --- /dev/null +++ b/contrib/lib/tests/static_files.rs @@ -0,0 +1,109 @@ +#![feature(plugin, decl_macro)] +#![plugin(rocket_codegen)] + +extern crate rocket; +extern crate rocket_contrib; + +#[cfg(feature = "static_files")] +mod static_files_tests { + use std::{io::Read, fs::File}; + use std::path::{Path, PathBuf}; + + use rocket::{self, Rocket}; + use rocket_contrib::static_files::{StaticFiles, Options}; + use rocket::http::Status; + use rocket::local::Client; + + fn static_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("static") + } + + fn rocket() -> Rocket { + let root = static_root(); + rocket::ignite() + .mount("/default", StaticFiles::from(&root)) + .mount("/no_index", StaticFiles::new(&root, Options::None)) + .mount("/dots", StaticFiles::new(&root, Options::DotFiles)) + .mount("/index", StaticFiles::new(&root, Options::Index)) + .mount("/both", StaticFiles::new(&root, Options::DotFiles | Options::Index)) + } + + static REGULAR_FILES: &[&str] = &[ + "index.html", + "inner/goodbye", + "inner/index.html", + "other/hello.txt", + ]; + + static HIDDEN_FILES: &[&str] = &[ + ".hidden", + "inner/.hideme", + ]; + + static INDEXED_DIRECTORIES: &[&str] = &[ + "", + "inner/", + ]; + + fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) { + let full_path = format!("/{}", Path::new(prefix).join(path).display()); + let mut response = client.get(full_path).dispatch(); + if exists { + assert_eq!(response.status(), Status::Ok); + + let mut path = static_root().join(path); + if path.is_dir() { + path = path.join("index.html"); + } + + let mut file = File::open(path).expect("open file"); + let mut expected_contents = String::new(); + file.read_to_string(&mut expected_contents).expect("read file"); + assert_eq!(response.body_string(), Some(expected_contents)); + } else { + assert_eq!(response.status(), Status::NotFound); + } + } + + fn assert_all(client: &Client, prefix: &str, paths: &[&str], exist: bool) { + paths.iter().for_each(|path| assert_file(client, prefix, path, exist)) + } + + #[test] + fn test_static_no_index() { + let client = Client::new(rocket()).expect("valid rocket"); + assert_all(&client, "no_index", REGULAR_FILES, true); + assert_all(&client, "no_index", HIDDEN_FILES, false); + assert_all(&client, "no_index", INDEXED_DIRECTORIES, false); + } + + #[test] + fn test_static_hidden() { + let client = Client::new(rocket()).expect("valid rocket"); + assert_all(&client, "dots", REGULAR_FILES, true); + assert_all(&client, "dots", HIDDEN_FILES, true); + assert_all(&client, "dots", INDEXED_DIRECTORIES, false); + } + + #[test] + fn test_static_index() { + let client = Client::new(rocket()).expect("valid rocket"); + assert_all(&client, "index", REGULAR_FILES, true); + assert_all(&client, "index", HIDDEN_FILES, false); + assert_all(&client, "index", INDEXED_DIRECTORIES, true); + + assert_all(&client, "default", REGULAR_FILES, true); + assert_all(&client, "default", HIDDEN_FILES, false); + assert_all(&client, "default", INDEXED_DIRECTORIES, true); + } + + #[test] + fn test_static_all() { + let client = Client::new(rocket()).expect("valid rocket"); + assert_all(&client, "both", REGULAR_FILES, true); + assert_all(&client, "both", HIDDEN_FILES, true); + assert_all(&client, "both", INDEXED_DIRECTORIES, true); + } +} diff --git a/examples/static_files/Cargo.toml b/examples/static_files/Cargo.toml index 19be0c72..c203d346 100644 --- a/examples/static_files/Cargo.toml +++ b/examples/static_files/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] rocket = { path = "../../core/lib" } rocket_codegen = { path = "../../core/codegen" } +rocket_contrib = { path = "../../contrib/lib" } diff --git a/examples/static_files/src/main.rs b/examples/static_files/src/main.rs index b5d92c2b..1a016c18 100644 --- a/examples/static_files/src/main.rs +++ b/examples/static_files/src/main.rs @@ -2,27 +2,14 @@ #![plugin(rocket_codegen)] extern crate rocket; +extern crate rocket_contrib; -#[cfg(test)] -mod tests; +#[cfg(test)] mod tests; -use std::io; -use std::path::{Path, PathBuf}; - -use rocket::response::NamedFile; - -#[get("/")] -fn index() -> io::Result { - NamedFile::open("static/index.html") -} - -#[get("/")] -fn files(file: PathBuf) -> Option { - NamedFile::open(Path::new("static/").join(file)).ok() -} +use rocket_contrib::static_files::StaticFiles; fn rocket() -> rocket::Rocket { - rocket::ignite().mount("/", routes![index, files]) + rocket::ignite().mount("/", StaticFiles::from("static")) } fn main() { diff --git a/examples/static_files/src/tests.rs b/examples/static_files/src/tests.rs index 95a5a4fa..c7b5d443 100644 --- a/examples/static_files/src/tests.rs +++ b/examples/static_files/src/tests.rs @@ -21,7 +21,7 @@ fn test_query_file (path: &str, file: T, status: Status) } fn read_file_content(path: &str) -> Vec { - let mut fp = File::open(&path).expect(&format!("Can not open {}", path)); + let mut fp = File::open(&path).expect(&format!("Can't open {}", path)); let mut file_content = vec![]; fp.read_to_end(&mut file_content).expect(&format!("Reading {} failed.", path));