Skip to content

Commit c211a38

Browse files
authored
feat: Enable caching of uploaded files (#24)
Makes the `/uploads/*` routes respond with appropriate headers to allow clients to cache the requests: - `last-modified` - `etag` - `cache-control` while additionally supporting the `if-none-match` request header. I'm not sure if the `last-modified` and `cache-control` headers are strictly necessary to allow the client browser to cache the requests, but adding them doesn't hurt and I don't have time to figure it out. :) This required adding `entity_tag` as a new dependency to the backend crate. Resolves #23
1 parent 4fc1512 commit c211a38

5 files changed

Lines changed: 109 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/backend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ default-run = "meta-tv-rs"
88
chrono = "0.4.40"
99
common = { path = "../common", features = ["entity"] }
1010
entity = { path = "../entity" }
11+
entity-tag = "0.1.8"
1112
migration = { path = "../migration" }
1213
openidconnect = { version = "4.0.0", features = ["timing-resistant-secret-traits"] }
1314
rocket = { version = "0.5.1", features = ["json", "secrets"] }

crates/backend/src/cached_file.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use std::{fs::Metadata, io, path::Path};
2+
3+
use chrono::{DateTime, Utc};
4+
use entity_tag::EntityTag;
5+
use rocket::{fs, http::Status, response, Request, Response};
6+
7+
/// Wrapper around [`rocket::fs::NamedFile`] which sets appropriate headers to allow better caching.
8+
///
9+
/// It adds the following response headers:
10+
/// - `etag`
11+
/// - `last-modified`
12+
/// - `cache-control` with a configured max age
13+
///
14+
/// It also responds to the `if-none-match` request header.
15+
pub struct CachedFile<'etag> {
16+
file: fs::NamedFile,
17+
metadata: Metadata,
18+
etag: EntityTag<'etag>,
19+
max_age: chrono::Duration,
20+
}
21+
22+
impl<'etag> CachedFile<'etag> {
23+
pub async fn open(
24+
path: impl AsRef<Path>,
25+
etag: EntityTag<'etag>,
26+
max_age: chrono::Duration,
27+
) -> io::Result<Self> {
28+
let file = fs::NamedFile::open(path).await?;
29+
let metadata = file.metadata().await?;
30+
31+
Ok(Self {
32+
file,
33+
metadata,
34+
etag,
35+
max_age,
36+
})
37+
}
38+
}
39+
40+
impl<'etag, 'r> response::Responder<'r, 'r> for CachedFile<'etag> {
41+
fn respond_to(self, req: &Request) -> response::Result<'r> {
42+
if let Some(etag) = req
43+
.headers()
44+
.get("if-none-match")
45+
.next()
46+
.and_then(|raw_etag| EntityTag::from_str(raw_etag).ok())
47+
{
48+
if etag == self.etag {
49+
return Response::build().status(Status::NotModified).ok();
50+
}
51+
}
52+
53+
let modification_time: DateTime<Utc> = self
54+
.metadata
55+
.modified()
56+
.expect("modification time is available on the relevant platforms")
57+
.into();
58+
59+
Response::build_from(self.file.respond_to(req)?)
60+
.raw_header(
61+
"last-modified",
62+
modification_time
63+
.format("%a, %d %b %Y %H:%M:%S GMT")
64+
.to_string(),
65+
)
66+
.raw_header("etag", format!("{}", self.etag))
67+
.raw_header(
68+
"cache-control",
69+
format!("max-age={}", self.max_age.num_seconds()),
70+
)
71+
.ok()
72+
}
73+
}

crates/backend/src/files.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
use std::path::{Path, PathBuf};
22

3+
use entity_tag::EntityTag;
34
use rocket::{
45
data::Capped,
56
fairing::{self, Fairing, Info, Kind},
6-
fs::{FileServer, TempFile},
7+
fs::TempFile,
78
tokio::io::AsyncReadExt,
8-
Build, Rocket,
9+
Build, Rocket, State,
910
};
1011
use sha2::{Digest, Sha256};
1112
use std::fmt::Write;
1213

13-
use crate::error::AppError;
14+
use crate::{cached_file::CachedFile, error::AppError};
1415

1516
pub struct FilesInitializer;
1617

@@ -55,19 +56,17 @@ impl Fairing for FilesInitializer {
5556

5657
info!("upload directory is set to {:?}", upload_dir);
5758

58-
let file_server = FileServer::from(&upload_dir);
5959
let files = Files { upload_dir };
60-
Ok(rocket.manage(files).mount("/uploads", file_server))
60+
Ok(rocket.manage(files).mount("/uploads", routes![uploads]))
6161
}
6262

6363
/// Handle using a temp dir for tests
6464
#[cfg(test)]
6565
async fn on_ignite(&self, rocket: Rocket<Build>) -> fairing::Result {
6666
let dir = tempfile::tempdir().unwrap();
6767

68-
let file_server = FileServer::from(dir.path());
6968
let files = Files { upload_dir: dir };
70-
Ok(rocket.manage(files).mount("/uploads", file_server))
69+
Ok(rocket.manage(files).mount("/uploads", routes![uploads]))
7170
}
7271
}
7372

@@ -127,3 +126,14 @@ impl Files {
127126
self.upload_dir.path()
128127
}
129128
}
129+
130+
#[get("/<path..>")]
131+
async fn uploads(path: PathBuf, files_config: &State<Files>) -> Option<CachedFile<'static>> {
132+
let path = files_config.get_path().join(&path);
133+
// Setting etag to filename as it is set to a hash of the contents on file upload.
134+
let etag = EntityTag::with_string(false, path.file_stem()?.to_str()?.to_owned()).ok()?;
135+
136+
CachedFile::open(path, etag, chrono::Duration::weeks(1))
137+
.await
138+
.ok()
139+
}

crates/backend/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use sea_orm_rocket::Database;
1616
extern crate rocket;
1717

1818
mod auth;
19+
mod cached_file;
1920
mod error;
2021
mod files;
2122
mod guards;

0 commit comments

Comments
 (0)