Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting Last-Modified Header for StaticFiles #1219

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// this is enabled, the [`StaticFiles`] handler will at the `Last-Modified`
/// this is enabled, the [`StaticFiles`] handler will add the `Last-Modified`

/// header baesd on the modification datetime of the files in the served
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// header baesd on the modification datetime of the files in the served
/// header based 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> {
schrieveslaach marked this conversation as resolved.
Show resolved Hide resolved
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);
}