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(¤t).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("<script>"));
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 `/` 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, "<script>alert(1)</script>");
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}