Skip to content

Commit

Permalink
Allow for optionally serving pre-compressed files.
Browse files Browse the repository at this point in the history
  • Loading branch information
hcldan committed Jan 30, 2024
1 parent 38dbab8 commit 83a2fc1
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 22 deletions.
48 changes: 35 additions & 13 deletions core/lib/src/fs/named_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ use crate::http::ContentType;
///
/// [`FileServer`]: crate::fs::FileServer
#[derive(Debug)]
pub struct NamedFile(PathBuf, File);
pub struct NamedFile {
path: PathBuf,
file: File,
/// If file ends in .gz, set `Content-Encoding` to gzip and use the base
/// extension for `Content-Type`
compressed: bool,
}

impl NamedFile {
/// Attempts to open a file in read-only mode.
Expand All @@ -64,13 +70,21 @@ impl NamedFile {
// 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))
let path = path.as_ref().to_path_buf();
let file = File::open(&path).await?;
Ok(NamedFile { path, file, compressed: false })
}

pub async fn open_with<P: AsRef<Path>>(path: P, opts: &OpenOptions) -> io::Result<NamedFile> {
let file = opts.open(path.as_ref()).await?;
Ok(NamedFile(path.as_ref().to_path_buf(), file))
let path = path.as_ref().to_path_buf();
let file = opts.open(&path).await?;
Ok(NamedFile { path, file, compressed: false })
}

pub async fn open_compressed<P: AsRef<Path>>(path: P) -> io::Result<NamedFile> {
let path = path.as_ref().to_path_buf();
let file = File::open(&path).await?;
Ok(NamedFile { path, file, compressed: true })
}

/// Retrieve the underlying `File`.
Expand All @@ -88,7 +102,7 @@ impl NamedFile {
/// ```
#[inline(always)]
pub fn file(&self) -> &File {
&self.1
&self.file
}

/// Retrieve a mutable borrow to the underlying `File`.
Expand All @@ -106,7 +120,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 @@ -124,7 +138,7 @@ impl NamedFile {
/// ```
#[inline(always)]
pub fn take_file(self) -> File {
self.1
self.file
}

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

Expand All @@ -153,8 +167,16 @@ 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() {
let mut response = self.file.respond_to(req)?;
if let Some(mut ext) = self.path.extension() {
let stripped = self.path.with_extension("");

if self.compressed && ext == std::ffi::OsStr::new("gz") {
response.set_raw_header("Content-Encoding", "gzip");
if let Some(orig_ext) = stripped.extension() {
ext = orig_ext; // override extension-based content type
}
}
if let Some(ct) = ContentType::from_extension(&ext.to_string_lossy()) {
response.set_header(ct);
}
Expand All @@ -168,12 +190,12 @@ 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
}
}
23 changes: 21 additions & 2 deletions core/lib/src/fs/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,22 @@ impl Handler for FileServer {
let index = NamedFile::open(p.join("index.html")).await;
index.respond_to(req).or_forward((data, Status::NotFound))
},
Some(p) => {
let file = NamedFile::open(p).await;
Some(mut p) => {
let check_compressed = options.contains(Options::CheckCompressed);
if check_compressed {
if let Some(file) = p.file_name() {
let mut compressed = file.to_os_string();
compressed.push(".gz");
let compressed_file = p.with_file_name(compressed);
if compressed_file.exists() {
p = compressed_file;
}
}
}
let file = match check_compressed {
true => NamedFile::open_compressed(p).await,
false => NamedFile::open(p).await,
};
file.respond_to(req).or_forward((data, Status::NotFound))
}
None => Outcome::forward(data, Status::NotFound),
Expand All @@ -257,6 +271,7 @@ impl Handler for FileServer {
/// * [`Options::Missing`] - Don't fail if the path to serve is missing.
/// * [`Options::NormalizeDirs`] - Redirect directories without a trailing
/// slash to ones with a trailing slash.
/// * [`Options::CheckCompressed`] - Serve pre-compressed files if they exist.
///
/// `Options` structures can be `or`d together to select two or more options.
/// For instance, to request that both dot files and index pages be returned,
Expand Down Expand Up @@ -366,6 +381,10 @@ impl Options {
/// prevent inevitable 404 errors. This option overrides that.
pub const Missing: Options = Options(1 << 4);

/// Check for and serve pre-zipped files on the filesystem based on
/// a similarly named file with a `.gz` extension.
pub const CheckCompressed: Options = Options(1 << 5);

/// 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
41 changes: 34 additions & 7 deletions core/lib/tests/file_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ fn rocket() -> Rocket<Build> {
.mount("/both", FileServer::new(&root, Options::DotFiles | Options::Index))
.mount("/redir", FileServer::new(&root, Options::NormalizeDirs))
.mount("/redir_index", FileServer::new(&root, Options::NormalizeDirs | Options::Index))
.mount("/compressed", FileServer::new(&root, Options::CheckCompressed))
}

static REGULAR_FILES: &[&str] = &[
"index.html",
"inner/goodbye",
"inner/index.html",
"other/hello.txt",
"other/hello.txt.gz",
];

static COMPRESSED_FILES: &[&str] = &[
"other/hello.txt",
];

static HIDDEN_FILES: &[&str] = &[
Expand All @@ -39,29 +45,42 @@ static INDEXED_DIRECTORIES: &[&str] = &[
"inner/",
];

fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool) {
fn assert_file(client: &Client, prefix: &str, path: &str, exists: bool, compressed: bool) {
let full_path = format!("/{}/{}", prefix, path);
let response = client.get(full_path).dispatch();
let mut response = client.get(full_path).dispatch();
if exists {
assert_eq!(response.status(), Status::Ok);

let mut path = static_root().join(path);
let mut path = match compressed {
true => static_root().join(format!("{path}.gz")),
false => static_root().join(path),
};
if path.is_dir() {
path = path.join("index.html");
}

let mut file = File::open(path).expect("open file");
let mut expected_contents = String::new();
file.read_to_string(&mut expected_contents).expect("read file");
assert_eq!(response.into_string(), Some(expected_contents));
let mut expected_contents = vec![];
file.read_to_end(&mut expected_contents).expect("read file");

let mut actual = vec![];
response.read_to_end(&mut actual).expect("read response");

let ce: Vec<&str> = response.headers().get("Content-Encoding").collect();
if compressed {
assert_eq!(vec!["gzip"], ce);
} else {
assert_eq!(Vec::<&str>::new(), ce);
}
assert_eq!(actual, expected_contents);
} else {
assert_eq!(response.status(), Status::NotFound);
}
}

fn assert_all(client: &Client, prefix: &str, paths: &[&str], exist: bool) {
for path in paths.iter() {
assert_file(client, prefix, path, exist);
assert_file(client, prefix, path, exist, false);
}
}

Expand Down Expand Up @@ -190,3 +209,11 @@ fn test_redirection() {
assert_eq!(response.status(), Status::PermanentRedirect);
assert_eq!(response.headers().get("Location").next(), Some("/redir_index/other/"));
}

#[test]
fn test_compression() {
let client = Client::debug(rocket()).expect("valid rocket");
for path in COMPRESSED_FILES {
assert_file(&client, "compressed", path, true, true)
}
}
Binary file added core/lib/tests/static/other/hello.txt.gz
Binary file not shown.

0 comments on commit 83a2fc1

Please sign in to comment.