#![deny(missing_docs)]
use async_trait::async_trait;
use churust_core::{AppBuilder, Call, Error, FromCallParts, Plugin, Response, Result};
use http::header::CONTENT_TYPE;
use http::{HeaderValue, StatusCode};
use minijinja::{AutoEscape, Environment};
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
pub use minijinja::context;
pub struct Templates {
env: Environment<'static>,
}
impl std::fmt::Debug for Templates {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Templates")
.field("loaded", &self.env.templates().count())
.finish_non_exhaustive()
}
}
impl Default for Templates {
fn default() -> Self {
Self::new()
}
}
impl Templates {
pub fn new() -> Self {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| AutoEscape::Html);
Self { env }
}
pub fn add(
mut self,
name: impl Into<String>,
source: impl Into<String>,
) -> std::result::Result<Self, minijinja::Error> {
self.env.add_template_owned(name.into(), source.into())?;
Ok(self)
}
pub fn from_dir(dir: impl AsRef<Path>) -> std::result::Result<Self, TemplateSetupError> {
let dir = dir.as_ref();
if !dir.is_dir() {
return Err(TemplateSetupError::MissingDir(dir.display().to_string()));
}
let mut templates = Self::new();
let mut pending = vec![dir.to_path_buf()];
let mut found = 0usize;
while let Some(current) = pending.pop() {
let entries = std::fs::read_dir(¤t).map_err(|e| {
TemplateSetupError::Io(current.display().to_string(), e.to_string())
})?;
for entry in entries {
let entry = entry.map_err(|e| {
TemplateSetupError::Io(current.display().to_string(), e.to_string())
})?;
let path = entry.path();
if path.is_dir() {
pending.push(path);
continue;
}
let source = std::fs::read_to_string(&path).map_err(|e| {
TemplateSetupError::Io(path.display().to_string(), e.to_string())
})?;
let name = relative_name(dir, &path);
templates = templates
.add(name.clone(), source)
.map_err(|e| TemplateSetupError::Parse(name, e.to_string()))?;
found += 1;
}
}
if found == 0 {
return Err(TemplateSetupError::Empty(dir.display().to_string()));
}
Ok(templates)
}
pub fn configure(mut self, f: impl FnOnce(&mut Environment<'static>)) -> Self {
f(&mut self.env);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateSetupError {
MissingDir(String),
Empty(String),
Io(String, String),
Parse(String, String),
}
impl std::fmt::Display for TemplateSetupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDir(path) => write!(f, "template directory not found: {path}"),
Self::Empty(path) => write!(f, "template directory holds no templates: {path}"),
Self::Io(path, why) => write!(f, "could not read {path}: {why}"),
Self::Parse(name, why) => write!(f, "template {name} failed to parse: {why}"),
}
}
}
impl std::error::Error for TemplateSetupError {}
fn relative_name(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
impl Plugin for Templates {
fn install(self: Box<Self>, app: &mut AppBuilder) {
app.insert_state(self.env);
}
}
#[derive(Clone)]
pub struct Renderer(Arc<Environment<'static>>);
impl std::fmt::Debug for Renderer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Renderer")
.field("templates", &self.0.templates().count())
.finish()
}
}
impl Renderer {
pub fn render(&self, name: &str, ctx: impl Serialize) -> Result<Response> {
let rendered = self
.0
.get_template(name)
.and_then(|t| t.render(ctx))
.map_err(|e| {
Error::internal("template rendering failed")
.with_source(RenderFailure(format!("{name}: {e:#}")))
})?;
let mut res = Response::text(rendered);
res.headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(res)
}
pub fn render_with_status(
&self,
status: StatusCode,
name: &str,
ctx: impl Serialize,
) -> Result<Response> {
Ok(self.render(name, ctx)?.with_status(status))
}
pub fn template_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.0
.templates()
.map(|(name, _)| name.to_string())
.collect();
names.sort();
names
}
}
#[derive(Debug)]
struct RenderFailure(String);
impl std::fmt::Display for RenderFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for RenderFailure {}
#[async_trait]
impl FromCallParts for Renderer {
async fn from_call_parts(call: &mut Call) -> Result<Self> {
match call.state::<Environment<'static>>() {
Some(env) => Ok(Renderer(env)),
None => Err(Error::internal(
"the Templates plugin is not installed on this server",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_template_that_does_not_parse_is_rejected_when_added() {
let err = Templates::new()
.add("broken.html", "{% for x in %}")
.expect_err("an unfinished tag should not be accepted");
assert!(!err.to_string().is_empty());
}
#[test]
fn a_missing_directory_is_a_startup_error() {
let err = Templates::from_dir("does/not/exist").unwrap_err();
assert!(matches!(err, TemplateSetupError::MissingDir(_)));
assert!(err.to_string().contains("does/not/exist"));
}
#[test]
fn names_are_relative_and_slash_separated() {
let root = Path::new("/srv/templates");
let path = Path::new("/srv/templates/mail/welcome.html");
assert_eq!(relative_name(root, path), "mail/welcome.html");
}
#[test]
fn html_templates_escape_interpolated_values() {
let templates = Templates::new()
.add("x.html", "{{ value }}")
.expect("parses");
let env = Arc::new(templates.env);
let view = Renderer(env);
let res = view
.render("x.html", context! { value => "<script>alert(1)</script>" })
.expect("renders");
let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
assert!(
!body.contains("<script>"),
"an .html template must escape markup: {body}"
);
assert!(body.contains("<script>"));
}
#[test]
fn a_template_without_an_html_extension_escapes_all_the_same() {
let templates = Templates::new()
.add("x.txt", "{{ value }}")
.expect("parses");
let view = Renderer(Arc::new(templates.env));
let res = view
.render("x.txt", context! { value => "<script>alert(1)</script>" })
.expect("renders");
let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
assert_eq!(body, "<script>alert(1)</script>");
}
#[test]
fn configure_can_hand_escaping_back_to_the_extension() {
let templates = Templates::new()
.configure(|env| env.set_auto_escape_callback(|_| AutoEscape::None))
.add("x.txt", "{{ value }}")
.expect("parses");
let view = Renderer(Arc::new(templates.env));
let res = view
.render("x.txt", context! { value => "a & b" })
.expect("renders");
let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
assert_eq!(body, "a & b");
}
#[test]
fn a_render_failure_does_not_leak_the_template_detail() {
let templates = Templates::new().add("x.html", "{{ ok }}").expect("parses");
let view = Renderer(Arc::new(templates.env));
let err = view.render("missing.html", context! {}).unwrap_err();
assert_eq!(err.message(), "template rendering failed");
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}