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 Nov 22, 2020
1 parent 1f1f44f commit efe0958
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 22 deletions.
21 changes: 18 additions & 3 deletions contrib/lib/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ impl Options {
/// directory, rather than its parent. This is _not_ enabled by default.
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 @@ -346,7 +352,7 @@ async fn handle_dir<'r, P>(opt: Options, r: &'r Request<'_>, d: Data, p: P) -> O
return Outcome::forward(d);
}

let file = NamedFile::open(p.as_ref().join("index.html")).await.ok();
let file = named_file(p.as_ref().join("index.html"), &opt).await;
Outcome::from_or_forward(r, d, file)
}

Expand All @@ -364,15 +370,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(p) if p.is_dir() => handle_dir(self.options, req, data, p).await,
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 @@ -44,6 +44,7 @@ serde = { version = "1.0", features = ["derive"] }
figment = { version = "0.9.2", features = ["toml", "env"] }
rand = "0.7"
either = "1"
headers = "0.3"

[dependencies.tokio]
version = "0.2.9"
Expand Down
84 changes: 70 additions & 14 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 All @@ -27,36 +34,62 @@ impl NamedFile {
/// ```rust
/// use rocket::response::NamedFile;
///
/// #[allow(unused_variables)]
/// # #[allow(unused_variables)]
/// # rocket::async_test(async {
/// let file = NamedFile::open("foo.txt").await;
/// });
/// # });
/// ```
pub async fn open<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
// FIXME: Grab the file size here and prohibit `seek`ing later (or else
// the file's effective size may change), to save on the cost of doing
// 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`.
#[inline(always)]
pub fn 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
}

/// Take the underlying `File`.
#[inline(always)]
pub fn take_file(self) -> File {
self.1
self.file
}

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

Expand All @@ -87,27 +120,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 @@ -13,11 +13,17 @@ mod manual {
pub async fn icon() -> Option<NamedFile> {
NamedFile::open("static/rocket-icon.jpg").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()
}

}

#[launch]
fn rocket() -> rocket::Rocket {
rocket::ignite()
.mount("/", routes![manual::icon])
.mount("/", routes![manual::icon, manual::cached_icon])
.mount("/", StaticFiles::from(crate_relative!("/static")))
}
67 changes: 63 additions & 4 deletions examples/static_files/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ 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;

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::tracked(rocket()).unwrap();
let 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::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 efe0958

Please sign in to comment.