diff --git a/components/site/src/lib.rs b/components/site/src/lib.rs index dc6e4331..74ad9273 100644 --- a/components/site/src/lib.rs +++ b/components/site/src/lib.rs @@ -534,7 +534,8 @@ impl Site { pub fn register_early_global_fns(&mut self) { self.tera.register_function( "get_url", - global_fns::GetUrl::new(self.config.clone(), self.permalinks.clone(), self.content_path.clone()), + global_fns::GetUrl::new(self.config.clone(), self.permalinks.clone(), + vec![self.static_path.clone(), self.output_path.clone(), self.content_path.clone()]), ); self.tera.register_function( "resize_image", @@ -550,6 +551,9 @@ impl Site { "get_taxonomy_url", global_fns::GetTaxonomyUrl::new(&self.config.default_language, &self.taxonomies), ); + self.tera.register_function("get_file_hash", global_fns::GetFileHash::new( + vec![self.static_path.clone(), self.output_path.clone(), self.content_path.clone()] + )); } pub fn register_tera_global_fns(&mut self) { diff --git a/components/site/tests/site.rs b/components/site/tests/site.rs index 773f0f36..331a2f31 100644 --- a/components/site/tests/site.rs +++ b/components/site/tests/site.rs @@ -686,6 +686,22 @@ fn can_ignore_markdown_content() { assert!(!file_exists!(public, "posts/ignored/index.html")); } +#[test] +fn can_cachebust_static_files() { + let (_, _tmp_dir, public) = build_site("test_site"); + assert!(file_contains!(public, "index.html", + "")); +} + +#[test] +fn can_get_hash_for_static_files() { + let (_, _tmp_dir, public) = build_site("test_site"); + assert!(file_contains!(public, "index.html", + "src=\"https://replace-this-with-your-url.com/scripts/hello.js\"")); + assert!(file_contains!(public, "index.html", + "integrity=\"sha384-01422f31eaa721a6c4ac8c6fa09a27dd9259e0dfcf3c7593d7810d912a9de5ca2f582df978537bcd10f76896db61fbb9\"")); +} + #[test] fn check_site() { let (mut site, _tmp_dir, _public) = build_site("test_site"); diff --git a/components/templates/src/global_fns/mod.rs b/components/templates/src/global_fns/mod.rs index 4535d8b7..563be571 100644 --- a/components/templates/src/global_fns/mod.rs +++ b/components/templates/src/global_fns/mod.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex, RwLock}; use std::{fs, io, result}; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256, Sha384, Sha512}; use tera::{from_value, to_value, Error, Function as TeraFn, Result, Value}; use config::Config; @@ -49,11 +49,11 @@ impl TeraFn for Trans { pub struct GetUrl { config: Config, permalinks: HashMap, - content_path: PathBuf, + search_paths: Vec, } impl GetUrl { - pub fn new(config: Config, permalinks: HashMap, content_path: PathBuf) -> Self { - Self { config, permalinks, content_path } + pub fn new(config: Config, permalinks: HashMap, search_paths: Vec) -> Self { + Self { config, permalinks, search_paths } } } @@ -74,12 +74,38 @@ fn make_path_with_lang(path: String, lang: &str, config: &Config) -> Result result::Result { - let mut file = fs::File::open(path)?; +fn open_file(search_paths: &Vec, url: &String) -> result::Result { + let cleaned_url = url.trim_start_matches("@/").trim_start_matches("/"); + for base_path in search_paths { + match fs::File::open(base_path.join(cleaned_url)) { + Ok(f) => return Ok(f), + Err(_) => continue + }; + } + Err(io::Error::from(io::ErrorKind::NotFound)) +} + +fn compute_file_sha256(mut file: fs::File) -> result::Result { let mut hasher = Sha256::new(); io::copy(&mut file, &mut hasher)?; Ok(format!("{:x}", hasher.result())) } +fn compute_file_sha384(mut file: fs::File) -> result::Result { + let mut hasher = Sha384::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.result())) +} +fn compute_file_sha512(mut file: fs::File) -> result::Result { + let mut hasher = Sha512::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.result())) +} + +fn file_not_found_err(search_paths: &Vec, url: &String) -> Result { + Err(format!("file `{}` not found; searched in{}", url, + search_paths.iter().fold(String::new(), + |acc, arg| acc + " " + arg.to_str().unwrap())).into()) +} impl TeraFn for GetUrl { fn call(&self, args: &HashMap) -> Result { @@ -120,10 +146,11 @@ impl TeraFn for GetUrl { } if cachebust { - let full_path = self.content_path.join(&path); - permalink = match compute_file_sha256(&full_path) { - Ok(digest) => format!("{}?h={}", permalink, digest), - Err(_) => return Err(format!("Could not read file `{}`. Expected location: {}", path, full_path.to_str().unwrap()).into()), + match open_file(&self.search_paths, &path).and_then(compute_file_sha256) { + Ok(hash) => { + permalink = format!("{}?h={}", permalink, hash); + }, + Err(_) => return file_not_found_err(&self.search_paths, &path) }; } Ok(to_value(permalink).unwrap()) @@ -131,6 +158,47 @@ impl TeraFn for GetUrl { } } +#[derive(Debug)] +pub struct GetFileHash { + search_paths: Vec, +} +impl GetFileHash { + pub fn new(search_paths: Vec) -> Self { + Self { search_paths } + } +} + +const DEFAULT_SHA_TYPE: u16 = 384; + +impl TeraFn for GetFileHash { + fn call(&self, args: &HashMap) -> Result { + let path = required_arg!( + String, + args.get("path"), + "`get_file_hash` requires a `path` argument with a string value" + ); + let sha_type = optional_arg!( + u16, + args.get("sha_type"), + "`get_file_hash`: `sha_type` must be 256, 384 or 512" + ).unwrap_or(DEFAULT_SHA_TYPE); + + let compute_hash_fn = match sha_type { + 256 => compute_file_sha256, + 384 => compute_file_sha384, + 512 => compute_file_sha512, + _ => return Err("`get_file_hash`: `sha_type` must be 256, 384 or 512".into()) + }; + + let hash = open_file(&self.search_paths, &path).and_then(compute_hash_fn); + + match hash { + Ok(digest) => Ok(to_value(digest).unwrap()), + Err(_) => file_not_found_err(&self.search_paths, &path) + } + } +} + #[derive(Debug)] pub struct ResizeImage { imageproc: Arc>, @@ -379,7 +447,7 @@ impl TeraFn for GetTaxonomy { #[cfg(test)] mod tests { - use super::{GetTaxonomy, GetTaxonomyUrl, GetUrl, Trans}; + use super::{GetTaxonomy, GetTaxonomyUrl, GetUrl, Trans, GetFileHash}; use std::collections::HashMap; use std::env::temp_dir; @@ -397,20 +465,20 @@ mod tests { use utils::slugs::SlugifyStrategy; struct TestContext { - content_path: PathBuf, + static_path: PathBuf, } impl TestContext { fn setup() -> Self { - let dir = temp_dir().join("test_global_fns"); + let dir = temp_dir().join("static"); create_directory(&dir).expect("Could not create test directory"); create_file(&dir.join("app.css"), "// Hello world!") .expect("Could not create test content (app.css)"); - Self { content_path: dir } + Self { static_path: dir } } } impl Drop for TestContext { fn drop(&mut self) { - remove_dir_all(&self.content_path).expect("Could not free test directory"); + remove_dir_all(&self.static_path).expect("Could not free test directory"); } } @@ -421,7 +489,7 @@ mod tests { #[test] fn can_add_cachebust_to_url() { let config = Config::default(); - let static_fn = GetUrl::new(config, HashMap::new(), TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, HashMap::new(), vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("app.css").unwrap()); args.insert("cachebust".to_string(), to_value(true).unwrap()); @@ -431,7 +499,7 @@ mod tests { #[test] fn can_add_trailing_slashes() { let config = Config::default(); - let static_fn = GetUrl::new(config, HashMap::new(), TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, HashMap::new(), vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("app.css").unwrap()); args.insert("trailing_slash".to_string(), to_value(true).unwrap()); @@ -441,7 +509,7 @@ mod tests { #[test] fn can_add_slashes_and_cachebust() { let config = Config::default(); - let static_fn = GetUrl::new(config, HashMap::new(), TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, HashMap::new(), vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("app.css").unwrap()); args.insert("trailing_slash".to_string(), to_value(true).unwrap()); @@ -452,7 +520,7 @@ mod tests { #[test] fn can_link_to_some_static_file() { let config = Config::default(); - let static_fn = GetUrl::new(config, HashMap::new(), TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, HashMap::new(), vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("app.css").unwrap()); assert_eq!(static_fn.call(&args).unwrap(), "http://a-website.com/app.css"); @@ -639,7 +707,7 @@ title = "A title" #[test] fn error_when_language_not_available() { let config = Config::parse(TRANS_CONFIG).unwrap(); - let static_fn = GetUrl::new(config, HashMap::new(), TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, HashMap::new(), vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("@/a_section/a_page.md").unwrap()); args.insert("lang".to_string(), to_value("it").unwrap()); @@ -662,7 +730,7 @@ title = "A title" "a_section/a_page.en.md".to_string(), "https://remplace-par-ton-url.fr/en/a_section/a_page/".to_string(), ); - let static_fn = GetUrl::new(config, permalinks, TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, permalinks, vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("@/a_section/a_page.md").unwrap()); args.insert("lang".to_string(), to_value("fr").unwrap()); @@ -684,7 +752,7 @@ title = "A title" "a_section/a_page.en.md".to_string(), "https://remplace-par-ton-url.fr/en/a_section/a_page/".to_string(), ); - let static_fn = GetUrl::new(config, permalinks, TEST_CONTEXT.content_path.clone()); + let static_fn = GetUrl::new(config, permalinks, vec![TEST_CONTEXT.static_path.clone()]); let mut args = HashMap::new(); args.insert("path".to_string(), to_value("@/a_section/a_page.md").unwrap()); args.insert("lang".to_string(), to_value("en").unwrap()); @@ -693,4 +761,42 @@ title = "A title" "https://remplace-par-ton-url.fr/en/a_section/a_page/" ); } + + #[test] + fn can_get_file_hash_sha256() { + let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]); + let mut args = HashMap::new(); + args.insert("path".to_string(), to_value("app.css").unwrap()); + args.insert("sha_type".to_string(), to_value(256).unwrap()); + assert_eq!(static_fn.call(&args).unwrap(), "572e691dc68c3fcd653ae463261bdb38f35dc6f01715d9ce68799319dd158840"); + } + + #[test] + fn can_get_file_hash_sha384() { + let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]); + let mut args = HashMap::new(); + args.insert("path".to_string(), to_value("app.css").unwrap()); + assert_eq!(static_fn.call(&args).unwrap(), "141c09bd28899773b772bbe064d8b718fa1d6f2852b7eafd5ed6689d26b74883b79e2e814cd69d5b52ab476aa284c414"); + } + + #[test] + fn can_get_file_hash_sha512() { + let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]); + let mut args = HashMap::new(); + args.insert("path".to_string(), to_value("app.css").unwrap()); + args.insert("sha_type".to_string(), to_value(512).unwrap()); + assert_eq!(static_fn.call(&args).unwrap(), "379dfab35123b9159d9e4e92dc90e2be44cf3c2f7f09b2e2df80a1b219b461de3556c93e1a9ceb3008e999e2d6a54b4f1d65ee9be9be63fa45ec88931623372f"); + } + + #[test] + fn error_when_file_not_found_for_hash() { + let static_fn = GetFileHash::new(vec![TEST_CONTEXT.static_path.clone()]); + let mut args = HashMap::new(); + args.insert("path".to_string(), to_value("doesnt-exist").unwrap()); + assert_eq!( + format!("file `doesnt-exist` not found; searched in {}", + TEST_CONTEXT.static_path.to_str().unwrap()), + format!("{}", static_fn.call(&args).unwrap_err()) + ); + } } diff --git a/docs/content/documentation/templates/overview.md b/docs/content/documentation/templates/overview.md index e9c967a7..257eb4cc 100644 --- a/docs/content/documentation/templates/overview.md +++ b/docs/content/documentation/templates/overview.md @@ -146,6 +146,24 @@ In the case of non-internal links, you can also add a cachebust of the format `? by passing `cachebust=true` to the `get_url` function. +### 'get_file_hash` + +Gets the hash digest for a static file. Supported hashes are SHA-256, SHA-384 (default) and SHA-512. Requires `path`. The `sha_type` key is optional and must be one of 256, 384 or 512. + +```jinja2 +{{/* get_file_hash(path="js/app.js", sha_type=256) */}} +``` + +This can be used to implement subresource integrity. Do note that subresource integrity is typically used when using external scripts, which `get_file_hash` does not support. + +```jinja2 + +``` + +Whenever hashing files, whether using `get_file_hash` or `get_url(..., cachebust=true)`, the file is searched for in three places: `static/`, `content/` and the output path (so e.g. compiled SASS can be hashed, too.) + + ### `get_image_metadata` Gets metadata for an image. Currently, the only supported keys are `width` and `height`. diff --git a/test_site/static/.gitattributes b/test_site/static/.gitattributes new file mode 100644 index 00000000..b47a8387 --- /dev/null +++ b/test_site/static/.gitattributes @@ -0,0 +1,3 @@ +# ensure consistent line endings (for hashes) +*.css text eol=lf +*.js text eol=lf diff --git a/test_site/static/scripts/hello.js b/test_site/static/scripts/hello.js index e69de29b..d28ad733 100644 --- a/test_site/static/scripts/hello.js +++ b/test_site/static/scripts/hello.js @@ -0,0 +1 @@ +// test content diff --git a/test_site/templates/index.html b/test_site/templates/index.html index 1e741a71..14bc3260 100644 --- a/test_site/templates/index.html +++ b/test_site/templates/index.html @@ -7,7 +7,7 @@ - + {{ config.title }} @@ -23,5 +23,7 @@ {% endblock content %} +