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 Feb 3, 2020
1 parent d0bfd8a commit 381b584
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 28 deletions.
33 changes: 24 additions & 9 deletions contrib/lib/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
//! features = ["serve"]
//! ```

use std::path::{PathBuf, Path};
use std::path::{Path, PathBuf};

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.
///
Expand Down Expand Up @@ -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`.
///
Expand Down Expand Up @@ -237,7 +240,11 @@ impl StaticFiles {
/// }
/// ```
pub fn new<P: AsRef<Path>>(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`.
Expand Down Expand Up @@ -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
Expand All @@ -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::<Segments<'_>>(0)
let path = req
.get_segments::<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<P: AsRef<Path>>(path: P, options: &Options) -> Option<NamedFile> {
if options.contains(Options::LastModifiedHeader) {
NamedFile::with_last_modified_date(path).ok()
} else {
NamedFile::open(path).ok()
}
}
1 change: 1 addition & 0 deletions core/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ base64 = "0.11"
base16 = "0.2"
pear = "0.1"
atty = "0.2"
httpdate = "0.3"

[build-dependencies]
yansi = "0.5"
Expand Down
65 changes: 53 additions & 12 deletions core/lib/src/response/named_file.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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::{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 All @@ -31,25 +37,35 @@ impl NamedFile {
/// ```
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
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<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
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.
Expand All @@ -69,7 +85,7 @@ impl NamedFile {
/// ```
#[inline(always)]
pub fn path(&self) -> &Path {
self.0.as_path()
self.path.as_path()
}
}

Expand All @@ -80,13 +96,38 @@ 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 httpdate::parse_http_date(if_modified_since) {
Ok(if_modified_since) => if_modified_since,
Err(_err) => return Response::build().status(Status::BadRequest).ok(),
};

if last_modified
.duration_since(if_modified_since)
.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)
}
}
Expand All @@ -95,13 +136,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
}
}

Expand Down
12 changes: 9 additions & 3 deletions examples/static_files/src/main.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
67 changes: 63 additions & 4 deletions examples/static_files/src/tests.rs
Original file line number Diff line number Diff line change
@@ -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<T> (path: &str, file: T, status: Status)
where T: Into<Option<&'static str>>
fn test_query_file<T>(path: &str, file: T, status: Status)
where
T: Into<Option<&'static str>>,
{
let client = Client::new(rocket()).unwrap();
let mut response = client.get(path).dispatch();
Expand All @@ -24,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 @@ -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);
}

0 comments on commit 381b584

Please sign in to comment.