Skip to main content

churust_templates/
lib.rs

1//! Server-rendered HTML for the [Churust] web framework, on [minijinja].
2//!
3//! Install [`Templates`], then take a [`Renderer`] in any handler:
4//!
5//! ```
6//! use churust_core::{Churust, TestClient};
7//! use churust_templates::{context, Renderer, Templates};
8//!
9//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
10//! let app = Churust::server()
11//!     .install(
12//!         Templates::new()
13//!             .add("hello.html", "<h1>Hello, {{ name }}!</h1>")
14//!             .expect("template should parse"),
15//!     )
16//!     .routing(|r| {
17//!         r.get("/", |view: Renderer| async move {
18//!             view.render("hello.html", context! { name => "world" })
19//!         });
20//!     })
21//!     .build();
22//!
23//! let res = TestClient::new(app).get("/").send().await;
24//! assert_eq!(res.text(), "<h1>Hello, world!</h1>");
25//! assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
26//! # });
27//! ```
28//!
29//! # Escaping
30//!
31//! Every template is HTML-escaped, whatever it is called. Interpolated values
32//! cannot inject markup, so a `{{ bio }}` holding `<script>` reaches the
33//! browser as text.
34//!
35//! minijinja's own rule is to pick the escaping from the file extension and
36//! escape only `.html`, `.htm` and `.xml`. That is the right default for a
37//! library that can render into any format, and the wrong one here, because
38//! [`Renderer`] has a single sink: every method it offers labels its reply
39//! `text/html; charset=utf-8`. Leaving the filename in charge meant a template
40//! named `page.txt`, or `partials/nav` with no extension at all, was
41//! interpolated raw and then served as HTML anyway — a stored XSS waiting on
42//! whoever named the file. Naming a template `.html` is still the clearer
43//! thing to do, but it is no longer what keeps you safe.
44//!
45//! If you need a template that is genuinely not HTML, install your own policy
46//! with [`Templates::configure`] **before** adding it: minijinja resolves the
47//! escaping once, when a template is parsed, so a callback set afterwards does
48//! not reach templates that are already loaded.
49//!
50//! # Templates are parsed at startup
51//!
52//! [`Templates::from_dir`] reads and parses every template when the server is
53//! built, so a syntax error is a startup failure with a filename in it rather
54//! than a `500` on the one route nobody visits until Friday. The cost is that a
55//! changed template needs a restart, which is the same trade the rest of the
56//! framework makes for routes and static roots.
57//!
58//! [Churust]: churust_core::Churust
59//! [minijinja]: https://docs.rs/minijinja
60
61#![deny(missing_docs)]
62
63use async_trait::async_trait;
64use churust_core::{AppBuilder, Call, Error, FromCallParts, Plugin, Response, Result};
65use http::header::CONTENT_TYPE;
66use http::{HeaderValue, StatusCode};
67use minijinja::{AutoEscape, Environment};
68use serde::Serialize;
69use std::path::Path;
70use std::sync::Arc;
71
72/// Build a template context inline.
73///
74/// Re-exported from minijinja so applications do not need their own dependency
75/// on it for the common case.
76///
77/// ```
78/// use churust_templates::context;
79///
80/// let ctx = context! { title => "Home", items => vec![1, 2, 3] };
81/// # let _ = ctx;
82/// ```
83pub use minijinja::context;
84
85/// The template environment, shared by every handler.
86///
87/// Construct with [`Templates::new`] or [`Templates::from_dir`], then hand it to
88/// [`AppBuilder::install`](churust_core::AppBuilder::install).
89pub struct Templates {
90    env: Environment<'static>,
91}
92
93impl std::fmt::Debug for Templates {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Templates")
96            .field("loaded", &self.env.templates().count())
97            .finish_non_exhaustive()
98    }
99}
100
101impl Default for Templates {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl Templates {
108    /// An empty environment. Add templates with [`add`](Templates::add).
109    pub fn new() -> Self {
110        let mut env = Environment::new();
111        // minijinja decides auto-escaping from the template's file extension,
112        // escaping only `.html`, `.htm` and `.xml` and leaving everything else
113        // raw. That rule is right for a library that can render into any
114        // format, and wrong here: [`Renderer::render`] has exactly one sink and
115        // stamps `text/html; charset=utf-8` on every reply it makes. Under the
116        // default callback a template the author called `page.txt`, or
117        // `partials/nav` with no extension at all, interpolated its values
118        // unescaped and was then shipped to a browser as HTML — a mislabelled
119        // response by construction, and a stored-XSS sink the moment one of
120        // those values came from a user. Pinning the policy to `Html` makes the
121        // escaping agree with the Content-Type instead of with the filename.
122        // `.html`/`.htm`/`.xml` are unaffected: the default already chose
123        // `Html` for all three.
124        env.set_auto_escape_callback(|_| AutoEscape::Html);
125        Self { env }
126    }
127
128    /// Add one template from a string, parsing it now.
129    ///
130    /// The name is what handlers and `{% extends %}` refer to. It no longer
131    /// decides escaping — see [Escaping](crate#escaping) — but `something.html`
132    /// still reads better than `something`.
133    ///
134    /// # Errors
135    ///
136    /// If the source does not parse. The error names the line.
137    pub fn add(
138        mut self,
139        name: impl Into<String>,
140        source: impl Into<String>,
141    ) -> std::result::Result<Self, minijinja::Error> {
142        self.env.add_template_owned(name.into(), source.into())?;
143        Ok(self)
144    }
145
146    /// Load and parse every template under `dir`, recursively.
147    ///
148    /// Template names are paths relative to `dir` with forward slashes, so
149    /// `templates/mail/welcome.html` is `mail/welcome.html`, which is also what
150    /// `{% extends %}` and `{% include %}` inside them refer to.
151    ///
152    /// Every file under `dir` is a template. There is no extension filter and
153    /// no skip list, so a `logo.png` or an editor artefact left beside the
154    /// templates is read as one too, and fails the boot with an [`Io`] error
155    /// naming the path if it is not valid UTF-8. That is the intended
156    /// behaviour rather than an oversight: a rule that quietly passed over
157    /// whatever did not look like a template would also pass over a real
158    /// template saved in the wrong encoding, and that omission would come back
159    /// as a `500` on the one route nobody exercises until Friday. Loud at boot
160    /// with the offending path in the message is the cheaper failure. Keep the
161    /// directory for templates and serve assets with `StaticFiles` from
162    /// `churust-core`'s `fs` feature.
163    ///
164    /// # Errors
165    ///
166    /// If `dir` is not a readable directory, if any file under it cannot be
167    /// read as UTF-8, or if any template fails to parse. All three are startup
168    /// failures on purpose: a template that cannot be parsed is a route that
169    /// cannot answer, and finding that out at boot is cheaper than finding out
170    /// in production.
171    ///
172    /// [`Io`]: TemplateSetupError::Io
173    pub fn from_dir(dir: impl AsRef<Path>) -> std::result::Result<Self, TemplateSetupError> {
174        let dir = dir.as_ref();
175        if !dir.is_dir() {
176            return Err(TemplateSetupError::MissingDir(dir.display().to_string()));
177        }
178
179        let mut templates = Self::new();
180        let mut pending = vec![dir.to_path_buf()];
181        let mut found = 0usize;
182
183        while let Some(current) = pending.pop() {
184            let entries = std::fs::read_dir(&current).map_err(|e| {
185                TemplateSetupError::Io(current.display().to_string(), e.to_string())
186            })?;
187            for entry in entries {
188                let entry = entry.map_err(|e| {
189                    TemplateSetupError::Io(current.display().to_string(), e.to_string())
190                })?;
191                let path = entry.path();
192                if path.is_dir() {
193                    pending.push(path);
194                    continue;
195                }
196                let source = std::fs::read_to_string(&path).map_err(|e| {
197                    TemplateSetupError::Io(path.display().to_string(), e.to_string())
198                })?;
199                let name = relative_name(dir, &path);
200                templates = templates
201                    .add(name.clone(), source)
202                    .map_err(|e| TemplateSetupError::Parse(name, e.to_string()))?;
203                found += 1;
204            }
205        }
206
207        if found == 0 {
208            return Err(TemplateSetupError::Empty(dir.display().to_string()));
209        }
210        Ok(templates)
211    }
212
213    /// Reach the underlying environment to register filters, tests, globals or
214    /// a custom auto-escape policy.
215    ///
216    /// Order matters for escaping and only for escaping: minijinja resolves a
217    /// template's auto-escape mode while it parses it, so a
218    /// `set_auto_escape_callback` here reaches only the templates added after
219    /// it. Filters, tests and globals are looked up at render time and can be
220    /// registered whenever.
221    ///
222    /// ```
223    /// use churust_templates::Templates;
224    ///
225    /// let templates = Templates::new().configure(|env| {
226    ///     env.add_filter("shout", |s: String| s.to_uppercase());
227    /// });
228    /// ```
229    pub fn configure(mut self, f: impl FnOnce(&mut Environment<'static>)) -> Self {
230        f(&mut self.env);
231        self
232    }
233}
234
235/// Why a template environment could not be built at startup.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum TemplateSetupError {
238    /// The directory does not exist or is not a directory.
239    MissingDir(String),
240    /// The directory exists but holds no templates, which is almost always a
241    /// wrong path rather than an intentionally empty site.
242    Empty(String),
243    /// A file could not be read.
244    Io(String, String),
245    /// A template could not be parsed. Carries the template name and the
246    /// parser's message, which includes the line.
247    Parse(String, String),
248}
249
250impl std::fmt::Display for TemplateSetupError {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::MissingDir(path) => write!(f, "template directory not found: {path}"),
254            Self::Empty(path) => write!(f, "template directory holds no templates: {path}"),
255            Self::Io(path, why) => write!(f, "could not read {path}: {why}"),
256            Self::Parse(name, why) => write!(f, "template {name} failed to parse: {why}"),
257        }
258    }
259}
260
261impl std::error::Error for TemplateSetupError {}
262
263/// A template name relative to the root, with forward slashes on every
264/// platform so `{% include %}` reads the same in a repository as it does in a
265/// container.
266fn relative_name(root: &Path, path: &Path) -> String {
267    path.strip_prefix(root)
268        .unwrap_or(path)
269        .components()
270        .map(|c| c.as_os_str().to_string_lossy().into_owned())
271        .collect::<Vec<_>>()
272        .join("/")
273}
274
275impl Plugin for Templates {
276    fn install(self: Box<Self>, app: &mut AppBuilder) {
277        // Held as application state rather than per-call data: one environment
278        // serves every request, and parsing happened at startup.
279        app.insert_state(self.env);
280    }
281}
282
283/// Renders templates from the installed [`Templates`] environment.
284///
285/// Extract it in a handler. Every method returns a [`Response`] with
286/// `Content-Type: text/html; charset=utf-8`.
287#[derive(Clone)]
288pub struct Renderer(Arc<Environment<'static>>);
289
290impl std::fmt::Debug for Renderer {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.debug_struct("Renderer")
293            .field("templates", &self.0.templates().count())
294            .finish()
295    }
296}
297
298impl Renderer {
299    /// Render `name` with `ctx` into a `200 OK` HTML response.
300    ///
301    /// # Errors
302    ///
303    /// If the template is unknown or rendering fails. The client is told only
304    /// that rendering failed: a minijinja error names the template file, the
305    /// line and often the offending variable, and none of that belongs in a
306    /// response body. The detail is attached as the error's source for the
307    /// application's own logging.
308    pub fn render(&self, name: &str, ctx: impl Serialize) -> Result<Response> {
309        let rendered = self
310            .0
311            .get_template(name)
312            .and_then(|t| t.render(ctx))
313            .map_err(|e| {
314                Error::internal("template rendering failed")
315                    .with_source(RenderFailure(format!("{name}: {e:#}")))
316            })?;
317
318        let mut res = Response::text(rendered);
319        res.headers.insert(
320            CONTENT_TYPE,
321            HeaderValue::from_static("text/html; charset=utf-8"),
322        );
323        Ok(res)
324    }
325
326    /// Render `name` with `ctx` and a status other than `200`.
327    ///
328    /// The obvious use is a `404` or `500` page from an
329    /// [`on_error`](churust_core::AppBuilder::on_error) hook.
330    pub fn render_with_status(
331        &self,
332        status: StatusCode,
333        name: &str,
334        ctx: impl Serialize,
335    ) -> Result<Response> {
336        Ok(self.render(name, ctx)?.with_status(status))
337    }
338
339    /// The names of every loaded template, for a health endpoint or a test.
340    pub fn template_names(&self) -> Vec<String> {
341        let mut names: Vec<String> = self
342            .0
343            .templates()
344            .map(|(name, _)| name.to_string())
345            .collect();
346        names.sort();
347        names
348    }
349}
350
351/// The detail behind a rendering failure, kept out of the response body.
352#[derive(Debug)]
353struct RenderFailure(String);
354
355impl std::fmt::Display for RenderFailure {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.write_str(&self.0)
358    }
359}
360
361impl std::error::Error for RenderFailure {}
362
363#[async_trait]
364impl FromCallParts for Renderer {
365    async fn from_call_parts(call: &mut Call) -> Result<Self> {
366        match call.state::<Environment<'static>>() {
367            Some(env) => Ok(Renderer(env)),
368            None => Err(Error::internal(
369                "the Templates plugin is not installed on this server",
370            )),
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn a_template_that_does_not_parse_is_rejected_when_added() {
381        let err = Templates::new()
382            .add("broken.html", "{% for x in %}")
383            .expect_err("an unfinished tag should not be accepted");
384        assert!(!err.to_string().is_empty());
385    }
386
387    #[test]
388    fn a_missing_directory_is_a_startup_error() {
389        let err = Templates::from_dir("does/not/exist").unwrap_err();
390        assert!(matches!(err, TemplateSetupError::MissingDir(_)));
391        assert!(err.to_string().contains("does/not/exist"));
392    }
393
394    #[test]
395    fn names_are_relative_and_slash_separated() {
396        let root = Path::new("/srv/templates");
397        let path = Path::new("/srv/templates/mail/welcome.html");
398        assert_eq!(relative_name(root, path), "mail/welcome.html");
399    }
400
401    #[test]
402    fn html_templates_escape_interpolated_values() {
403        let templates = Templates::new()
404            .add("x.html", "{{ value }}")
405            .expect("parses");
406        let env = Arc::new(templates.env);
407        let view = Renderer(env);
408        let res = view
409            .render("x.html", context! { value => "<script>alert(1)</script>" })
410            .expect("renders");
411        let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
412        assert!(
413            !body.contains("<script>"),
414            "an .html template must escape markup: {body}"
415        );
416        assert!(body.contains("&lt;script&gt;"));
417    }
418
419    #[test]
420    fn a_template_without_an_html_extension_escapes_all_the_same() {
421        // The extension used to decide this and the Content-Type did not, so
422        // the two could disagree. Now only the Content-Type has a say.
423        let templates = Templates::new()
424            .add("x.txt", "{{ value }}")
425            .expect("parses");
426        let view = Renderer(Arc::new(templates.env));
427        let res = view
428            .render("x.txt", context! { value => "<script>alert(1)</script>" })
429            .expect("renders");
430        let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
431        // The closing slash comes back as `&#x2f;` for the same reason it does
432        // in an `.html` template: the escaper will not leave a value anything
433        // it could use to close a tag with.
434        assert_eq!(body, "&lt;script&gt;alert(1)&lt;&#x2f;script&gt;");
435    }
436
437    #[test]
438    fn configure_can_hand_escaping_back_to_the_extension() {
439        // The escape hatch for an author who really is rendering something
440        // that is not HTML. It has to run before `add`, because minijinja
441        // resolves the mode as it parses.
442        let templates = Templates::new()
443            .configure(|env| env.set_auto_escape_callback(|_| AutoEscape::None))
444            .add("x.txt", "{{ value }}")
445            .expect("parses");
446        let view = Renderer(Arc::new(templates.env));
447        let res = view
448            .render("x.txt", context! { value => "a & b" })
449            .expect("renders");
450        let body = String::from_utf8(res.body.as_slice().unwrap().to_vec()).unwrap();
451        assert_eq!(body, "a & b");
452    }
453
454    #[test]
455    fn a_render_failure_does_not_leak_the_template_detail() {
456        let templates = Templates::new().add("x.html", "{{ ok }}").expect("parses");
457        let view = Renderer(Arc::new(templates.env));
458        let err = view.render("missing.html", context! {}).unwrap_err();
459        assert_eq!(err.message(), "template rendering failed");
460        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
461    }
462}