diff --git a/contrib/lib/src/serve.rs b/contrib/lib/src/serve.rs index 8e1c12c650..42ecbe0101 100644 --- a/contrib/lib/src/serve.rs +++ b/contrib/lib/src/serve.rs @@ -16,10 +16,10 @@ use std::path::{PathBuf, Path}; -use rocket::{Request, Data, Route}; -use rocket::http::{Method, uri::Segments}; use rocket::handler::{Handler, Outcome}; +use rocket::http::{uri::Segments, Method}; use rocket::response::NamedFile; +use rocket::{Data, Request, Route}; /// A bitset representing configurable options for the [`StaticFiles`] handler. /// @@ -53,6 +53,9 @@ impl Options { /// directories beginning with `.`. This is _not_ enabled by default. pub const DotFiles: Options = Options(0b0010); + /// `Options` enabling caching based on the `Last-Modified` header. + pub const LastModifiedHeader: Options = Options(0b0100); + /// 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`. /// @@ -237,7 +240,11 @@ impl StaticFiles { /// } /// ``` pub fn new>(path: P, options: Options) -> Self { - StaticFiles { root: path.as_ref().into(), options, rank: Self::DEFAULT_RANK } + StaticFiles { + root: path.as_ref().into(), + options, + rank: Self::DEFAULT_RANK, + } } /// Sets the rank for generated routes to `rank`. @@ -279,8 +286,7 @@ impl Handler for StaticFiles { return Outcome::forward(d); } - let file = NamedFile::open(path.join("index.html")).ok(); - Outcome::from_or_forward(r, d, file) + Outcome::from_or_forward(r, d, named_file(path.join("index.html"), &opt)) } // If this is not the route with segments, handle it only if the user @@ -294,15 +300,24 @@ impl Handler for StaticFiles { // 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) + let path = req + .get_segments::>(0) .and_then(|res| res.ok()) .and_then(|segments| segments.into_path_buf(allow_dotfiles).ok()) .map(|path| self.root.join(path)); match &path { Some(path) if path.is_dir() => handle_dir(self.options, req, data, path), - Some(path) => Outcome::from_or_forward(req, data, NamedFile::open(path).ok()), - None => Outcome::forward(data) + Some(path) => Outcome::from_or_forward(req, data, named_file(path, &self.options)), + None => Outcome::forward(data), } } } + +fn named_file>(path: P, options: &Options) -> Option { + if options.contains(Options::LastModifiedHeader) { + NamedFile::with_last_modified_date(path).ok() + } else { + NamedFile::open(path).ok() + } +} diff --git a/core/http/src/hyper.rs b/core/http/src/hyper.rs index 587387dcb7..e9dcd6967f 100644 --- a/core/http/src/hyper.rs +++ b/core/http/src/hyper.rs @@ -25,7 +25,7 @@ pub mod header { use crate::Header; - use hyper::header::Header as HyperHeaderTrait; + pub use hyper::header::Header as HyperHeaderTrait; macro_rules! import_hyper_items { ($($item:ident),*) => ($(pub use hyper::header::$item;)*) diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index f4a1646983..7252592632 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -36,6 +36,7 @@ memchr = "2" # TODO: Use pear instead. binascii = "0.1" pear = "0.1" atty = "0.2" +httpdate = "0.3" [build-dependencies] yansi = "0.5" diff --git a/core/lib/src/response/named_file.rs b/core/lib/src/response/named_file.rs index 5c98d6aafe..fd49c0cb14 100644 --- a/core/lib/src/response/named_file.rs +++ b/core/lib/src/response/named_file.rs @@ -1,16 +1,23 @@ use std::fs::File; -use std::path::{Path, PathBuf}; use std::io; use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use crate::http::hyper::header::{HyperHeaderTrait, IfModifiedSince}; +use crate::http::{ContentType, Header, Status}; use crate::request::Request; use crate::response::{self, Responder}; -use crate::http::ContentType; +use crate::Response; /// A file with an associated name; responds with the Content-Type based on the /// file extension. #[derive(Debug)] -pub struct NamedFile(PathBuf, File); +pub struct NamedFile { + path: PathBuf, + file: File, + modified: Option, +} impl NamedFile { /// Attempts to open a file in read-only mode. @@ -31,25 +38,35 @@ impl NamedFile { /// ``` pub fn open>(path: P) -> io::Result { let file = File::open(path.as_ref())?; - Ok(NamedFile(path.as_ref().to_path_buf(), file)) + Ok(NamedFile { + path: path.as_ref().to_path_buf(), + file, + modified: None, + }) + } + + pub fn with_last_modified_date>(path: P) -> io::Result { + let mut named_file = NamedFile::open(path)?; + named_file.modified = Some(named_file.path().metadata()?.modified()?); + Ok(named_file) } /// Retrieve the underlying `File`. #[inline(always)] pub fn file(&self) -> &File { - &self.1 + &self.file } /// Take the underlying `File`. #[inline(always)] pub fn take_file(self) -> File { - self.1 + self.file } /// Retrieve a mutable borrow to the underlying `File`. #[inline(always)] pub fn file_mut(&mut self) -> &mut File { - &mut self.1 + &mut self.file } /// Retrieve the path of this file. @@ -69,7 +86,7 @@ impl NamedFile { /// ``` #[inline(always)] pub fn path(&self) -> &Path { - self.0.as_path() + self.path.as_path() } } @@ -80,13 +97,44 @@ impl NamedFile { /// implied by its extension, use a [`File`] directly. impl Responder<'_> for NamedFile { fn respond_to(self, req: &Request<'_>) -> response::Result<'static> { - let mut response = self.1.respond_to(req)?; - if let Some(ext) = self.0.extension() { + if let Some(if_modified_since) = req.headers().get("If-Modified-Since").next() { + if let Some(last_modified) = &self.modified { + let if_modified_since = + match IfModifiedSince::parse_header(&vec![if_modified_since + .to_string() + .into_bytes()]) + { + Ok(if_modified_since) => { + Duration::from_secs((if_modified_since.0).0.to_timespec().sec as u64) + } + Err(_err) => return Response::build().status(Status::BadRequest).ok(), + }; + + if (*last_modified - if_modified_since) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(Duration::from_secs(60)) + <= Duration::from_secs(1) + { + return Response::build().status(Status::NotModified).ok(); + } + } + } + + let mut response = self.file.respond_to(req)?; + if let Some(ext) = self.path.extension() { if let Some(ct) = ContentType::from_extension(&ext.to_string_lossy()) { response.set_header(ct); } } + let last_modified = self + .modified + .map(|modified| httpdate::fmt_http_date(modified)); + + if let Some(last_modified) = last_modified { + response.set_header(Header::new("Last-Modified", last_modified)); + } + Ok(response) } } @@ -95,13 +143,13 @@ impl Deref for NamedFile { type Target = File; fn deref(&self) -> &File { - &self.1 + &self.file } } impl DerefMut for NamedFile { fn deref_mut(&mut self) -> &mut File { - &mut self.1 + &mut self.file } } diff --git a/examples/static_files/src/main.rs b/examples/static_files/src/main.rs index ce4a1776f1..5dac89c03a 100644 --- a/examples/static_files/src/main.rs +++ b/examples/static_files/src/main.rs @@ -1,12 +1,18 @@ extern crate rocket; extern crate rocket_contrib; -#[cfg(test)] mod tests; +#[cfg(test)] +mod tests; -use rocket_contrib::serve::StaticFiles; +use rocket_contrib::serve::{Options, StaticFiles}; fn rocket() -> rocket::Rocket { - rocket::ignite().mount("/", StaticFiles::from("static")) + rocket::ignite() + .mount("/", StaticFiles::from("static")) + .mount( + "/with-caching", + StaticFiles::new("static", Options::LastModifiedHeader).rank(100), + ) } fn main() { diff --git a/examples/static_files/src/tests.rs b/examples/static_files/src/tests.rs index c7b5d44344..d701ca8980 100644 --- a/examples/static_files/src/tests.rs +++ b/examples/static_files/src/tests.rs @@ -1,13 +1,14 @@ use std::fs::File; use std::io::Read; +use rocket::http::{Header, Status}; use rocket::local::Client; -use rocket::http::Status; use super::rocket; -fn test_query_file (path: &str, file: T, status: Status) - where T: Into> +fn test_query_file(path: &str, file: T, status: Status) +where + T: Into>, { let client = Client::new(rocket()).unwrap(); let mut response = client.get(path).dispatch(); @@ -24,7 +25,8 @@ fn read_file_content(path: &str) -> Vec { 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)); + fp.read_to_end(&mut file_content) + .expect(&format!("Reading {} failed.", path)); file_content } @@ -54,3 +56,60 @@ fn test_invalid_path() { test_query_file("/thou/shalt/not/exist", None, Status::NotFound); test_query_file("/thou/shalt/not/exist?a=b&c=d", None, Status::NotFound); } + +#[test] +fn test_valid_last_modified() { + let client = Client::new(rocket()).unwrap(); + let response = client.get("/with-caching/rocket-icon.jpg").dispatch(); + assert_eq!(response.status(), Status::Ok); + + let last_modified = response + .headers() + .get("Last-Modified") + .next() + .expect("Response should contain Last-Modified header") + .to_string(); + + let mut request = client.get("/with-caching/rocket-icon.jpg"); + request.add_header(Header::new("If-Modified-Since".to_string(), last_modified)); + let response = request.dispatch(); + + assert_eq!(response.status(), Status::NotModified); +} + +#[test] +fn test_none_matching_last_modified() { + let client = Client::new(rocket()).unwrap(); + + let mut request = client.get("/with-caching/rocket-icon.jpg"); + request.add_header(Header::new( + "If-Modified-Since".to_string(), + "Wed, 21 Oct 2015 07:28:00 GMT", + )); + let response = request.dispatch(); + + assert_eq!(response.status(), Status::Ok); + + let mut request = client.get("/with-caching/rocket-icon.jpg"); + request.add_header(Header::new( + "If-Modified-Since".to_string(), + "Wed, 21 Oct 2344 07:28:00 GMT", + )); + let response = request.dispatch(); + + assert_eq!(response.status(), Status::Ok); +} + +#[test] +fn test_invalid_last_modified() { + let client = Client::new(rocket()).unwrap(); + + let mut request = client.get("/with-caching/rocket-icon.jpg"); + request.add_header(Header::new( + "If-Modified-Since".to_string(), + "random header", + )); + let response = request.dispatch(); + + assert_eq!(response.status(), Status::BadRequest); +}