From e9aea561a5a892de81a588ab235090f615ec44ba Mon Sep 17 00:00:00 2001 From: Marc Schreiber Date: Sat, 1 Feb 2020 12:13:21 +0100 Subject: [PATCH] Supporting Last-Modified Header for StaticFiles This commit adds basic support of Last-Modified and If-Modified-Since headers in order to enable HTTP caching while serving static files. --- contrib/lib/src/serve.rs | 18 ++++++- core/lib/Cargo.toml | 1 + core/lib/src/response/named_file.rs | 80 ++++++++++++++++++++++++----- examples/static-files/src/main.rs | 8 ++- examples/static-files/src/tests.rs | 62 +++++++++++++++++++++- 5 files changed, 152 insertions(+), 17 deletions(-) diff --git a/contrib/lib/src/serve.rs b/contrib/lib/src/serve.rs index f57912c47c..09b7172695 100644 --- a/contrib/lib/src/serve.rs +++ b/contrib/lib/src/serve.rs @@ -133,6 +133,12 @@ impl Options { /// that would be served is a directory. pub const NormalizeDirs: Options = Options(0b0100); + /// `Options` enabling caching based on the `Last-Modified` header. When + /// this is enabled, the [`StaticFiles`] handler will at the `Last-Modified` + /// header baesd on the modification datetime of the files in the served + /// directory. + pub const LastModifiedHeader: Options = Options(0b1000); + /// 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`. /// @@ -381,11 +387,19 @@ impl Handler for StaticFiles { return Outcome::forward(data); } - let index = NamedFile::open(p.join("index.html")).await.ok(); + let index = named_file(p.join("index.html"), &self.options).await; Outcome::from_or_forward(req, data, index) }, - Some(p) => Outcome::from_or_forward(req, data, NamedFile::open(p).await.ok()), + Some(p) => Outcome::from_or_forward(req, data, named_file(p, &self.options).await), None => Outcome::forward(data), } } } + +async fn named_file>(path: P, options: &Options) -> Option { + if options.contains(Options::LastModifiedHeader) { + NamedFile::with_last_modified_date(path).await.ok() + } else { + NamedFile::open(path).await.ok() + } +} diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index 112fb0c627..ef4d679c61 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -45,6 +45,7 @@ indexmap = { version = "1.0", features = ["serde-1"] } tempfile = "3" async-trait = "0.1.43" multer = { version = "2", features = ["tokio-io"] } +headers = "0.3" [dependencies.async-stream] git = "https://github.com/SergioBenitez/async-stream.git" diff --git a/core/lib/src/response/named_file.rs b/core/lib/src/response/named_file.rs index e810ceeb8d..74b69f4dd8 100644 --- a/core/lib/src/response/named_file.rs +++ b/core/lib/src/response/named_file.rs @@ -1,17 +1,24 @@ use std::io; -use std::path::{Path, PathBuf}; use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use headers::{Header as HeaderTrait, HeaderValue, IfModifiedSince}; use tokio::fs::File; +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. @@ -39,7 +46,33 @@ impl NamedFile { // all of those `seek`s to determine the file size. But, what happens if // the file gets changed between now and then? let file = File::open(path.as_ref()).await?; - Ok(NamedFile(path.as_ref().to_path_buf(), file)) + Ok(NamedFile { + path: path.as_ref().to_path_buf(), + file, + modified: None, + }) + } + + /// Attempts to open a file in the same manner as `NamedFile::open` and + /// reads the modification timestamp of the file that will be used to + /// respond with the `Last-Modified` header. This enables HTTP caching by + /// comparing the modification timestamp with the `If-Modified-Since` + /// header when requesting the file. + /// + /// # Examples + /// + /// ```rust + /// use rocket::response::NamedFile; + /// + /// # #[allow(unused_variables)] + /// # rocket::async_test(async { + /// let file = NamedFile::with_last_modified_date("foo.txt").await; + /// # }); + /// ``` + pub async fn with_last_modified_date>(path: P) -> io::Result { + let mut named_file = NamedFile::open(path).await?; + named_file.modified = named_file.metadata().await?.modified().ok(); + Ok(named_file) } /// Retrieve the underlying `File`. @@ -57,7 +90,7 @@ impl NamedFile { /// ``` #[inline(always)] pub fn file(&self) -> &File { - &self.1 + &self.file } /// Retrieve a mutable borrow to the underlying `File`. @@ -75,7 +108,7 @@ impl NamedFile { /// ``` #[inline(always)] pub fn file_mut(&mut self) -> &mut File { - &mut self.1 + &mut self.file } /// Take the underlying `File`. @@ -93,7 +126,7 @@ impl NamedFile { /// ``` #[inline(always)] pub fn take_file(self) -> File { - self.1 + self.file } /// Retrieve the path of this file. @@ -111,7 +144,7 @@ impl NamedFile { /// ``` #[inline(always)] pub fn path(&self) -> &Path { - self.0.as_path() + self.path.as_path() } } @@ -122,27 +155,50 @@ impl NamedFile { /// implied by its extension, use a [`File`] directly. impl<'r> Responder<'r, 'static> for NamedFile { fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - let mut response = self.1.respond_to(req)?; - if let Some(ext) = self.0.extension() { + if let Some(last_modified) = &self.modified { + if let Some(if_modified_since) = req.headers().get_one("If-Modified-Since") { + if let Ok(if_modified_since) = parse_if_modified_since(if_modified_since) { + if !if_modified_since.is_modified(*last_modified) { + 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); } } + if let Some(last_modified) = self.modified.map(|m| IfModifiedSince::from(m)) { + let mut headers = Vec::with_capacity(1); + last_modified.encode(&mut headers); + let v = headers[0].to_str().unwrap(); + response.set_header(Header::new("Last-Modified", v.to_string())); + } + Ok(response) } } +fn parse_if_modified_since(header: &str) -> Result { + let headers = vec![HeaderValue::from_str(header).map_err(|e| e.to_string())?]; + let mut headers_it = headers.iter(); + Ok(IfModifiedSince::decode(&mut headers_it).map_err(|e| e.to_string())?) +} + 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 ad6f2d4065..58c4409750 100644 --- a/examples/static-files/src/main.rs +++ b/examples/static-files/src/main.rs @@ -17,11 +17,17 @@ mod manual { NamedFile::open(path).await.ok() } + + #[rocket::get("/with-caching/rocket-icon.jpg")] + pub async fn cached_icon() -> Option { + NamedFile::with_last_modified_date("static/rocket-icon.jpg").await.ok() + } + } #[rocket::launch] fn rocket() -> _ { rocket::build() - .mount("/", rocket::routes![manual::second]) + .mount("/", rocket::routes![manual::second, manual::cached_icon]) .mount("/", StaticFiles::from(crate_relative!("static"))) } diff --git a/examples/static-files/src/tests.rs b/examples/static-files/src/tests.rs index b437f77df3..79ea87b315 100644 --- a/examples/static-files/src/tests.rs +++ b/examples/static-files/src/tests.rs @@ -2,7 +2,7 @@ use std::fs::File; use std::io::Read; use rocket::local::blocking::Client; -use rocket::http::Status; +use rocket::http::{Header, Status}; use super::rocket; @@ -25,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 } @@ -69,3 +70,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::tracked(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::tracked(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 1900 07:28:00 GMT", + )); + let response = request.dispatch(); + + assert_eq!(response.status(), Status::Ok); +} + +#[test] +fn test_invalid_last_modified() { + let client = Client::tracked(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::Ok); +}