Skip to content

Commit

Permalink
Supporting Last-Modified Header for StaticFiles
Browse files Browse the repository at this point in the history
This commit adds basic support of Last-Modified and If-Modified-Since headers
in order to enable HTTP caching while serving static files.
  • Loading branch information
schrieveslaach committed May 20, 2021
1 parent fa3e033 commit e9aea56
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 17 deletions.
18 changes: 16 additions & 2 deletions contrib/lib/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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<P: AsRef<Path>>(path: P, options: &Options) -> Option<NamedFile> {
if options.contains(Options::LastModifiedHeader) {
NamedFile::with_last_modified_date(path).await.ok()
} else {
NamedFile::open(path).await.ok()
}
}
1 change: 1 addition & 0 deletions core/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 68 additions & 12 deletions core/lib/src/response/named_file.rs
Original file line number Diff line number Diff line change
@@ -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<SystemTime>,
}

impl NamedFile {
/// Attempts to open a file in read-only mode.
Expand Down Expand Up @@ -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<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
let mut named_file = NamedFile::open(path).await?;
named_file.modified = named_file.metadata().await?.modified().ok();
Ok(named_file)
}

/// Retrieve the underlying `File`.
Expand All @@ -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`.
Expand All @@ -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`.
Expand All @@ -93,7 +126,7 @@ impl NamedFile {
/// ```
#[inline(always)]
pub fn take_file(self) -> File {
self.1
self.file
}

/// Retrieve the path of this file.
Expand All @@ -111,7 +144,7 @@ impl NamedFile {
/// ```
#[inline(always)]
pub fn path(&self) -> &Path {
self.0.as_path()
self.path.as_path()
}
}

Expand All @@ -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<IfModifiedSince, String> {
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
}
}
8 changes: 7 additions & 1 deletion examples/static-files/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
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")))
}
62 changes: 60 additions & 2 deletions examples/static-files/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,7 +25,8 @@ fn read_file_content(path: &str) -> Vec<u8> {
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
}

Expand Down Expand Up @@ -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);
}

0 comments on commit e9aea56

Please sign in to comment.