Skip to main content

umbral_core/
templates.rs

1//! Server-side HTML rendering via minijinja.
2//!
3//! Templates live under one or more directories on disk. At boot,
4//! `App::build()` assembles an ordered search list:
5//!
6//! 1. The project-level directory configured via
7//!    `AppBuilder::templates_dir` (default `./templates`).
8//! 2. Each registered plugin's `Plugin::templates_dirs()` contributions,
9//!    in topological dependency order.
10//!
11//! The first directory that contains a given template name wins. This
12//! makes cross-plugin `{% extends "base.html" %}` work automatically —
13//! the extends lookup searches every directory the same way a direct
14//! render call does. Plugin A can extend `base.html` from plugin B as
15//! long as B's directory appears in the search list.
16//!
17//! When two directories both provide a template with the same name, the
18//! first-match-wins policy applies and a `tracing::warn!` is emitted at
19//! boot so the collision is visible in the log. First-match-wins across
20//! all template directories. Silently-overridden templates are a
21//! well-known footgun, so the warning is non-optional.
22//!
23//! Rendering goes through one ambient accessor, [`render`], which reads
24//! the engine the App builder published into an `OnceLock` during build.
25//!
26//! ```ignore
27//! let html = umbral::templates::render("articles_list.html", &context!(articles))?;
28//! ```
29//!
30//! ## Autoescape
31//!
32//! Any template whose name ends in `.html` or `.htm` renders with
33//! autoescape on. Text templates (`.txt`) render verbatim. The autoescape
34//! callback extension whitelist MUST stay in sync with the loader's
35//! `load_directory` filter (currently `html | htm | txt`).
36//!
37//! ## v1 scope
38//!
39//! - One project-level templates directory (default `./templates/`,
40//!   relative to the binary's cwd) plus per-plugin directories.
41//! - Jinja2-compatible syntax via minijinja: `{% extends %}`, `{% block %}`,
42//!   `{% if %}`, `{% for %}`, `{{ value }}`, the standard filter set.
43//! - Autoescape for any template whose name ends in `.html` or `.htm`.
44//! - Init is best-effort: if no directory exists the engine boots empty.
45//!   Calls to [`render`] then return `TemplateError::Missing`.
46//!
47//! ## Deferred
48//!
49//! - Custom filters and tests registered through `Plugin::on_ready`.
50//! - Hot reload in development via `minijinja-autoreload`.
51
52use std::collections::HashSet;
53use std::future::Future;
54use std::path::{Path, PathBuf};
55use std::pin::Pin;
56use std::sync::Arc;
57use std::sync::OnceLock;
58
59use minijinja::{AutoEscape, Environment};
60use syntect::highlighting::ThemeSet;
61use syntect::html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style};
62use syntect::parsing::SyntaxSet;
63use syntect::util::LinesWithEndings;
64
65tokio::task_local! {
66    /// Per-request ambient user value, set by a session-aware layer
67    /// (typically `umbral_sessions::UserContextLayer<U>`) and read by
68    /// [`render`] to expose the current `user` in
69    /// templates. `None` means an anonymous request.
70    ///
71    /// Outside the layer's scope, `try_with` returns `Err(AccessError)`
72    /// and `render` skips the merge — explicit ctx behaviour is
73    /// preserved when no layer is installed.
74    pub static CURRENT_USER: Option<minijinja::Value>;
75
76    /// Per-request CSRF token, set by `umbral-security`'s middleware and
77    /// read by [`render`] to inject `csrf_token` / `csrf_input` into
78    /// every template, for the `{% csrf_token %}` ergonomic. Outside
79    /// the middleware's scope nothing is injected (a template that
80    /// references `{{ csrf_token }}` then renders it empty under the
81    /// engine's lenient-undefined behaviour).
82    pub static CURRENT_CSRF: Option<String>;
83
84    /// Lazy counterpart to `CURRENT_USER`: a resolver that produces the
85    /// user value on first access, memoized. Set by an auth middleware that
86    /// wants per-request laziness (resolve only if a template reads `user`).
87    pub static CURRENT_USER_LAZY: LazyUser;
88}
89
90type UserFut = Pin<Box<dyn Future<Output = minijinja::Value> + Send>>;
91type UserResolver = Arc<dyn Fn() -> UserFut + Send + Sync>;
92
93/// A lazily-resolved, per-request template `user`. The `resolver` runs at
94/// most once (guarded by the `OnceCell`); resolution happens synchronously
95/// from inside minijinja's sync render via `block_in_place`.
96///
97/// The lazy value is injected into the template context as a minijinja `Object`
98/// proxy ([`LazyUserProxy`]). Minijinja calls `get_value` on the proxy only
99/// when the template actually accesses an attribute on `user`, so requests that
100/// never render `user` skip resolution entirely.
101#[derive(Clone)]
102pub struct LazyUser {
103    cell: Arc<tokio::sync::OnceCell<minijinja::Value>>,
104    resolver: UserResolver,
105}
106
107impl LazyUser {
108    pub fn new<F, Fut>(resolver: F) -> Self
109    where
110        F: Fn() -> Fut + Send + Sync + 'static,
111        Fut: Future<Output = minijinja::Value> + Send + 'static,
112    {
113        Self {
114            cell: Arc::new(tokio::sync::OnceCell::new()),
115            resolver: Arc::new(move || Box::pin(resolver())),
116        }
117    }
118
119    /// Resolve (memoized) from a synchronous context. Requires a multi-thread
120    /// tokio runtime; on a current-thread runtime or outside any runtime it
121    /// logs and returns the anonymous value so callers fall back cleanly.
122    fn resolve_blocking(&self) -> minijinja::Value {
123        use tokio::runtime::{Handle, RuntimeFlavor};
124        let Ok(handle) = Handle::try_current() else {
125            return anonymous_user_value();
126        };
127        if handle.runtime_flavor() == RuntimeFlavor::CurrentThread {
128            tracing::warn!(
129                "umbral::templates: lazy `user` needs a multi-thread runtime; rendering anonymous"
130            );
131            return anonymous_user_value();
132        }
133        let cell = self.cell.clone();
134        let resolver = self.resolver.clone();
135        tokio::task::block_in_place(move || {
136            handle.block_on(async move { cell.get_or_init(|| resolver()).await.clone() })
137        })
138    }
139
140    /// Wrap this `LazyUser` in a minijinja `Value` proxy that resolves on
141    /// first attribute access from inside the synchronous render loop.
142    fn into_proxy_value(self) -> minijinja::Value {
143        minijinja::Value::from_object(LazyUserProxy(self))
144    }
145}
146
147/// A minijinja Object proxy that defers resolution of the user until the
148/// template actually accesses an attribute (e.g. `{{ user.is_staff }}`).
149/// Minijinja calls `get_value` for attribute access — we resolve there, not
150/// at context-merge time.
151struct LazyUserProxy(LazyUser);
152
153impl std::fmt::Debug for LazyUserProxy {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.write_str("LazyUserProxy")
156    }
157}
158
159impl std::fmt::Display for LazyUserProxy {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        // `{{ user }}` — resolve (memoized) and delegate to the resolved
162        // value's own Display so bare rendering is faithful. Uses the same
163        // `resolve_blocking` path as `get_value` to ensure at-most-once
164        // resolution and the same current-thread / no-runtime fallback.
165        let resolved = self.0.resolve_blocking();
166        std::fmt::Display::fmt(&resolved, f)
167    }
168}
169
170impl minijinja::value::Object for LazyUserProxy {
171    fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
172        let resolved = self.0.resolve_blocking();
173        resolved.get_item(key).ok()
174    }
175
176    fn is_true(self: &Arc<Self>) -> bool {
177        // `{% if user %}` — resolve (memoized) and delegate to the resolved
178        // value's truthiness so the proxy faithfully represents whether the
179        // resolved value is truthy. Uses the same `resolve_blocking` path as
180        // `get_value` so resolution is still at most once per request.
181        self.0.resolve_blocking().is_true()
182    }
183}
184
185/// Scope a lazy `user` resolver for the duration of `fut`.
186pub async fn with_current_user_lazy<F: Future>(lazy: LazyUser, fut: F) -> F::Output {
187    CURRENT_USER_LAZY.scope(lazy, fut).await
188}
189
190/// Run `fut` with the ambient template user value scoped to `user`
191/// for its duration. Intended for the session-aware layer in
192/// `umbral-sessions`; downstream handler code reads the value
193/// transparently through [`render`].
194pub async fn with_current_user<F: std::future::Future>(
195    user: Option<minijinja::Value>,
196    fut: F,
197) -> F::Output {
198    CURRENT_USER.scope(user, fut).await
199}
200
201/// Run `fut` with the ambient CSRF token scoped for its duration.
202/// Intended for the CSRF middleware in `umbral-security`; downstream
203/// handler code reads the value transparently through [`render`]
204/// (as `{{ csrf_token }}` / `{{ csrf_input }}`) or [`current_csrf`].
205pub async fn with_current_csrf<F: std::future::Future>(token: Option<String>, fut: F) -> F::Output {
206    CURRENT_CSRF.scope(token, fut).await
207}
208
209/// Read the ambient CSRF token, if a middleware has scoped one for
210/// this request. Non-template consumers (e.g. the admin's login form
211/// builder) use this to embed the same token the middleware minted,
212/// instead of minting their own.
213pub fn current_csrf() -> Option<String> {
214    CURRENT_CSRF.try_with(|t| t.clone()).ok().flatten()
215}
216
217/// Watched template directories captured at `init` time. Stored
218/// separately so the dev-mode render path can rebuild the environment
219/// from the same sources without re-publishing the OnceLock.
220static WATCHED_DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
221use serde::Serialize;
222
223static ENGINE: OnceLock<Environment<'static>> = OnceLock::new();
224
225/// A plugin-contributed mutation of the template [`Environment`]: adds
226/// custom filters, functions, or globals at engine-build time
227/// (feature #67 - custom template tags/filters). Returned by
228/// `Plugin::template_registrars` and stored process-wide so the dev-mode
229/// hot-reload rebuild re-applies it.
230///
231/// It is `Fn` (not `FnOnce`) on purpose: in dev mode the engine is
232/// rebuilt on every template edit, so each registrar runs once per build.
233/// Make it owned and `'static` (no borrows of the plugin) so it survives
234/// in the [`REGISTRARS`] handle past `App::build`.
235pub type TemplateRegistrar = Box<dyn Fn(&mut Environment<'static>) + Send + Sync>;
236
237/// Plugin-contributed [`TemplateRegistrar`]s captured at `init_with` time.
238/// Stored separately from [`ENGINE`] so the dev-mode rebuild path (which
239/// goes through [`build_env`]) re-applies them without the App builder.
240static REGISTRARS: OnceLock<Vec<TemplateRegistrar>> = OnceLock::new();
241
242/// Register the built-in default 404/500 templates into an environment.
243///
244/// Called from `init` before any disk directories are scanned. The names
245/// use the `__umbral__/` prefix so they can never collide with a user's
246/// `templates/` directory (slashes aren't meaningful to the engine's name
247/// lookup — `__umbral__/default_404.html` is just a unique string key).
248///
249/// Because the user's disk directories are added after this call and
250/// first-match-wins is enforced by the `seen` set, a user who places a
251/// file named `__umbral__/default_404.html` in their own templates dir will
252/// silently replace the built-in — which is the intended escape hatch.
253/// (Callers who want a cleaner opt-out should use
254/// `App::builder().disable_default_error_pages()` instead.)
255/// gaps2 #21 — register the `img` MiniJinja filter that turns a URL
256/// into a fully-formed, performance-correct `<img>` tag.
257///
258/// Filter signature:
259///   `{{ url | img(alt="…", width=N, height=N, class="…") }}`
260///
261/// Output shape:
262///   `<img src="<url>" alt="<alt>" loading="lazy" decoding="async"
263///        width="<w>" height="<h>" class="<class>">`
264///
265/// Why this set of attributes:
266/// - `loading="lazy"` — the gap's primary ask. Browsers defer
267///   off-viewport image fetches until they're about to be needed,
268///   shrinking LCP + initial bandwidth.
269/// - `decoding="async"` — lets the browser decode the image off
270///   the main thread; prevents render-blocking decode work on
271///   slower devices.
272/// - explicit `width`/`height` (when provided) reserves layout
273///   space immediately so lazy-loading doesn't cause CLS
274///   (cumulative layout shift). Omitted if either is missing.
275/// - empty `alt=""` default is screen-reader-friendly for
276///   decorative images. Callers SHOULD pass a real `alt` for
277///   meaningful content images.
278///
279/// What's NOT included on day one (deferred to a later slice):
280/// - `srcset` for responsive resolutions — needs the on-the-fly
281///   resize handler (gap 21 Option C) before the filter knows
282///   real asset dimensions.
283/// - `<picture>` with `webp`/`avif` sources — same blocker; the
284///   transcode endpoint has to exist first.
285///
286/// Output is wrapped in `minijinja::value::Value::from_safe_string`
287/// so MiniJinja's autoescape doesn't double-escape the `<` / `>`
288/// characters — the attribute values themselves still go through
289/// `html_escape` so a hostile alt-text can't break out of the
290/// attribute quote.
291/// True when `url` is safe to place in an `<img src>`: a relative URL
292/// (no scheme) or an `http`/`https` absolute URL. Any other scheme
293/// (`javascript:`, `data:`, `vbscript:`, …) is rejected. Fails closed:
294/// a malformed scheme (embedded control chars, spaces) is also rejected.
295fn url_scheme_is_safe(url: &str) -> bool {
296    let trimmed = url.trim();
297    // A URL scheme is the run before the first ':' — but only if no
298    // '/', '?', '#' appears first (those mean a relative path/query).
299    let mut scheme_end = None;
300    for (i, c) in trimmed.char_indices() {
301        match c {
302            ':' => {
303                scheme_end = Some(i);
304                break;
305            }
306            '/' | '?' | '#' => break,
307            _ => {}
308        }
309    }
310    let Some(end) = scheme_end else {
311        return true; // no scheme → relative URL → safe
312    };
313    let scheme = &trimmed[..end];
314    // A real scheme is alpha then [a-z0-9+.-]*. Anything else is suspicious.
315    let mut chars = scheme.chars();
316    let well_formed = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
317        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
318    if !well_formed {
319        return false;
320    }
321    let lower = scheme.to_ascii_lowercase();
322    lower == "http" || lower == "https"
323}
324
325fn register_img_filter(env: &mut Environment<'static>) {
326    env.add_filter(
327        "img",
328        |url: String,
329         kwargs: minijinja::value::Kwargs|
330         -> Result<minijinja::Value, minijinja::Error> {
331            let alt: String = kwargs.get::<Option<String>>("alt")?.unwrap_or_default();
332            let width: Option<i64> = kwargs.get("width")?;
333            let height: Option<i64> = kwargs.get("height")?;
334            let class: Option<String> = kwargs.get("class")?;
335            // Accept the extras even when the call doesn't pass them
336            // — kwargs.get returns Ok(None) for absent keys but
337            // .assert_all_used() at the end will catch a typo'd
338            // `alt_text` so the user gets a clear error instead of
339            // silent drop. Matches the rest of the framework's
340            // strict-input posture.
341            kwargs.assert_all_used()?;
342
343            // Defense-in-depth: never emit a `javascript:` / `data:` /
344            // other non-http(s) scheme into `src`. Not a live XSS (browsers
345            // don't run JS from `<img src>` and the value is HTML-escaped),
346            // but a hostile stored URL has no business here. A disallowed
347            // scheme neutralises to an empty src (broken image) rather than
348            // erroring the whole page on user data.
349            let url = if url_scheme_is_safe(&url) {
350                url
351            } else {
352                String::new()
353            };
354
355            let mut out = String::with_capacity(url.len() + 128);
356            out.push_str("<img src=\"");
357            html_escape_into(&mut out, &url);
358            out.push_str("\" alt=\"");
359            html_escape_into(&mut out, &alt);
360            out.push_str("\" loading=\"lazy\" decoding=\"async\"");
361            if let Some(w) = width {
362                out.push_str(" width=\"");
363                out.push_str(&w.to_string());
364                out.push('"');
365            }
366            if let Some(h) = height {
367                out.push_str(" height=\"");
368                out.push_str(&h.to_string());
369                out.push('"');
370            }
371            if let Some(c) = class {
372                if !c.is_empty() {
373                    out.push_str(" class=\"");
374                    html_escape_into(&mut out, &c);
375                    out.push('"');
376                }
377            }
378            out.push('>');
379            Ok(minijinja::Value::from_safe_string(out))
380        },
381    );
382}
383
384/// features.md #4 — register the `markdown` filter that turns a
385/// CommonMark + GFM string into sanitized HTML.
386///
387/// Filter signature: `{{ body | markdown }}`.
388///
389/// Pipeline:
390/// 1. `pulldown-cmark` parses the input with GFM extensions on
391///    (tables, strikethrough, task lists, footnotes) and renders to
392///    HTML.
393/// 2. `ammonia` sanitizes that HTML — strips `<script>`, inline event
394///    handlers (`onerror=`, `onclick=`), `javascript:` URLs, and any
395///    tag/attribute outside its safe allowlist. This is the security
396///    boundary: user-supplied markdown (plugin bodies, usage docs,
397///    reviews) is rendered, never trusted.
398/// 3. The result is wrapped in `Value::from_safe_string` so MiniJinja's
399///    autoescape emits the generated tags as markup instead of
400///    re-escaping them into `&lt;...&gt;`.
401///
402/// Why sanitize after rendering rather than trusting the parser: raw
403/// HTML embedded in a markdown source (`<script>...`) passes straight
404/// through pulldown-cmark by design. ammonia is the layer that makes
405/// "render whatever the user typed" safe.
406///
407/// Deferred (separate slices): syntax highlighting on fenced code
408/// blocks (ammonia strips the `language-*` class today) and a
409/// configurable allowlist for embeds — see the gap entries.
410/// Register the global `static()` template function so templates can
411/// write `{{ static("admin/admin.css") }}` and get back a URL prefixed
412/// with the configured `static_url`.
413///
414/// `static_url` is captured into the closure when the environment is
415/// built (rather than read per-call) — the value is fixed for the
416/// process at `App::build()` time, and minijinja functions can't reach
417/// the ambient `Settings` directly. The dev-mode render path rebuilds
418/// the env per render via [`build_env`], so a `static_url` change would
419/// be picked up there too; in practice it never changes at runtime.
420///
421/// Resolution joins `static_url` and the argument with exactly one
422/// slash: a leading slash on the argument (`static("/admin/x")`) is
423/// trimmed so the result never double-slashes. With the default
424/// `static_url = "/static/"`, `static("admin/admin.css")` yields
425/// `"/static/admin/admin.css"`; with a CDN origin
426/// `static_url = "https://cdn.example.com/s/"` it yields
427/// `"https://cdn.example.com/s/admin/admin.css"`.
428/// A generated URL, returned to the template as SAFE unless it contains a character that
429/// could break out of the attribute it lands in (gaps3 #66).
430///
431/// `static()` and `media_url()` used to return a plain `String`, which minijinja
432/// autoescapes in HTML context — so `{{ static('css/app.css') }}` rendered as
433/// `href="&#x2f;static&#x2f;css&#x2f;app.css"`. Browsers decode `&#x2f;` back to `/`, so
434/// the stylesheet still loaded and the pages still looked right; it was working by
435/// accident, and any reader of the page source would reasonably conclude static serving
436/// was broken.
437///
438/// Marking the URL safe is what fixes it — but marking it *unconditionally* safe would be
439/// an XSS hole: `media_url(key)` takes a key that came from an uploaded filename, i.e.
440/// from a user, and a key containing `"` closes the `href` attribute. So a URL carrying
441/// any HTML-special character stays escaped. The path a template author writes by hand
442/// (`css/app.css`) never contains one; a hostile filename does, and it keeps its armour.
443pub fn safe_url(url: String) -> minijinja::Value {
444    // Escape if the value carries an HTML-special character (a hostile
445    // filename closing the `href` attribute) OR carries a dangerous URL
446    // scheme (`javascript:`, `data:`) — the char check alone let a
447    // scheme-based `href="javascript:…"` through unescaped (stored XSS in
448    // a staff-viewed admin page). `url_scheme_is_safe` allows only relative
449    // URLs and http/https, matching the img filter's guard.
450    if url.contains(['<', '>', '"', '\'', '&']) || !url_scheme_is_safe(&url) {
451        minijinja::Value::from(url)
452    } else {
453        minijinja::Value::from_safe_string(url)
454    }
455}
456
457fn register_static_function(env: &mut Environment<'static>, static_url: String) {
458    env.add_function("static", move |path: String| -> minijinja::Value {
459        // Route through the manifest-aware resolver so a `--hashed`
460        // collect makes `{{ static("css/app.css") }}` emit the
461        // content-hashed URL. The captured `static_url` is the fixed
462        // prefix; the manifest lookup is the only per-call ambient read.
463        if let Some(hashed) = crate::static_files::manifest_lookup(&path) {
464            return safe_url(join_static_url(&static_url, hashed));
465        }
466        safe_url(join_static_url(&static_url, &path))
467    });
468}
469
470/// Join a `static_url` prefix and an asset path with exactly one slash.
471///
472/// `static_url` is normalised to end in a slash by [`crate::settings`];
473/// the asset path may or may not lead with one, so its leading slash is
474/// trimmed before the join. With `static_url = "/static/"`,
475/// `join_static_url(.., "admin/admin.css")` yields
476/// `"/static/admin/admin.css"`.
477fn join_static_url(static_url: &str, path: &str) -> String {
478    format!("{}{}", static_url, path.trim_start_matches('/'))
479}
480
481/// Resolve an asset path against the ambient `static_url`, mirroring the
482/// `static()` template global outside a minijinja render.
483///
484/// Plugins that build their own minijinja [`Environment`] (the admin
485/// engine, for one) call this to register an equivalent `static()`
486/// function so their templates can write `{{ static("admin/admin.css") }}`
487/// and resolve through the same unified static pipeline URL as the core
488/// engine. Reads `static_url` from ambient [`crate::settings`], defaulting
489/// to `/static/` when settings aren't initialised yet (bare unit tests).
490pub fn resolve_static_url(path: &str) -> String {
491    let static_url = crate::settings::get_opt()
492        .map(|s| s.static_url.clone())
493        .unwrap_or_else(|| "/static/".to_string());
494
495    // Manifest cache-busting (hashed static-file storage): when
496    // `collectstatic --hashed` has run, a `staticfiles.json` maps the
497    // logical path the template wrote (`css/app.css`) to its
498    // content-hashed name (`css/app.<hash>.css`). Resolving to the hashed
499    // URL lets the asset carry far-future cache headers — the hash in the
500    // name changes whenever the bytes do, so a stale cache can never mask
501    // a new build. When no manifest is loaded (no `--hashed` run), the
502    // lookup misses and we serve the plain path exactly as before.
503    if let Some(hashed) = crate::static_files::manifest_lookup(path) {
504        return join_static_url(&static_url, hashed);
505    }
506
507    join_static_url(&static_url, path)
508}
509
510/// Register the global `media_url()` template function so a template can
511/// write `{{ media_url(plugin.logo) }}` and get back the public URL for a
512/// stored file/image KEY, resolved through the ambient
513/// [`crate::storage::Storage`] backend.
514///
515/// Mirrors the `static()` global ([`register_static_function`]) but for
516/// user-uploaded media instead of developer-shipped assets:
517/// `ImageField` / `FileField` serialize as the bare storage key, so
518/// `{{ media_url(plugin.logo) }}` (where `plugin.logo` is the key string)
519/// resolves to the storage backend's public URL.
520///
521/// - An empty key yields the empty string (the surrounding `{% if %}`
522///   guard skips the markup).
523/// - With no `Storage` backend registered, the raw key falls through
524///   unchanged.
525/// - A `None`/optional field serializes to null, which the template's
526///   `{% if %}` guard handles before the helper is ever called.
527fn register_media_url_function(env: &mut Environment<'static>) {
528    env.add_function("media_url", |key: String| -> minijinja::Value {
529        if key.is_empty() {
530            return minijinja::Value::from("");
531        }
532        let url = crate::storage::storage_opt()
533            .map(|s| s.url(&key))
534            .unwrap_or(key);
535        // NOTE: `key` is an uploaded filename — user-controlled. `safe_url` keeps the
536        // escaping on any URL carrying an HTML-special character, so a hostile name
537        // cannot break out of the attribute.
538        safe_url(url)
539    });
540}
541
542/// Register the `{{ querystring_with(current_query, key, value) }}` global
543/// (gaps/features #65 — template pagination). Rebuilds a querystring
544/// replacing one key while preserving every other parameter, the fiddly bit
545/// behind a pagination nav that has to carry `?sort=name` across every
546/// `?page=N` link. Backed by [`crate::pagination::querystring_with`] so the
547/// encode/replace logic stays in one place and is unit-tested there. The
548/// returned string has no leading `?`; the template prepends one.
549fn register_querystring_with_function(env: &mut Environment<'static>) {
550    env.add_function(
551        "querystring_with",
552        // `value` is a `minijinja::Value`, not a `String`: the nav passes
553        // `page.next_page_number` / `item.n`, which are integers, and
554        // minijinja does NOT auto-coerce an int arg into a `String`
555        // parameter — it'd raise a type error at render. Accepting `Value`
556        // and stringifying covers ints, strings, and bools uniformly.
557        |current_query: String, key: String, value: minijinja::Value| -> String {
558            crate::pagination::querystring_with(&current_query, &key, &value.to_string())
559        },
560    );
561}
562
563fn register_markdown_filter(env: &mut Environment<'static>) {
564    env.add_filter("markdown", |input: String| -> minijinja::Value {
565        minijinja::Value::from_safe_string(render_markdown(&input))
566    });
567}
568
569/// Register the `{{ highlight_styles() }}` global: emits the generated
570/// `base16-ocean.dark` token stylesheet wrapped in a `<style>` block, for a
571/// base template to drop into `<head>` once. The CSS is a safe string
572/// (generated by syntect from a fixed theme, no user input), so it is
573/// marked safe to skip minijinja autoescape.
574fn register_highlight_styles_function(env: &mut Environment<'static>) {
575    env.add_function("highlight_styles", || -> minijinja::Value {
576        minijinja::Value::from_safe_string(format!("<style>{}</style>", highlight_css()))
577    });
578}
579
580/// features.md #67 — `{{ now() }}` / `{{ now("%Y-%m-%d") }}`. Renders the
581/// current UTC time, optionally via a chrono `strftime` format string.
582/// With no argument it emits RFC 3339 (e.g. `2026-06-13T10:30:00+00:00`).
583/// The reference built-in tag for the custom-tag surface.
584fn register_now_function(env: &mut Environment<'static>) {
585    env.add_function("now", |fmt: Option<String>| -> String {
586        let now = chrono::Utc::now();
587        match fmt {
588            Some(f) if !f.is_empty() => now.format(&f).to_string(),
589            _ => now.to_rfc3339(),
590        }
591    });
592}
593
594/// features.md #67 — `{{ price | currency }}` / `{{ price | currency("EUR") }}`.
595/// Formats a number as money: two decimals, thousands grouping, and a
596/// leading symbol for the common ISO codes (USD/EUR/GBP/JPY); an unknown
597/// code falls back to `1,234.56 CODE`. The reference built-in filter.
598fn register_currency_filter(env: &mut Environment<'static>) {
599    env.add_filter("currency", |amount: f64, code: Option<String>| -> String {
600        let code = code.unwrap_or_else(|| "USD".to_string());
601        let symbol = match code.as_str() {
602            "USD" | "AUD" | "CAD" | "NZD" => "$",
603            "EUR" => "€",
604            "GBP" => "£",
605            "JPY" | "CNY" => "¥",
606            "KES" => "KSh ",
607            _ => "",
608        };
609        // Sign goes outside the symbol: -$12.40, not $-12.40.
610        let sign = if amount < 0.0 { "-" } else { "" };
611        let body = group_thousands(amount.abs());
612        if symbol.is_empty() {
613            format!("{sign}{body} {code}")
614        } else {
615            format!("{sign}{symbol}{body}")
616        }
617    });
618}
619
620/// Format a float with two decimals and comma thousands separators on the
621/// integer part: `1234567.5 -> "1,234,567.50"`, `-12.4 -> "-12.40"`.
622fn group_thousands(amount: f64) -> String {
623    let negative = amount.is_sign_negative() && amount != 0.0;
624    let formatted = format!("{:.2}", amount.abs());
625    let (int_part, frac_part) = formatted.split_once('.').unwrap_or((&formatted, "00"));
626
627    let mut grouped = String::new();
628    let digits: Vec<char> = int_part.chars().collect();
629    for (i, ch) in digits.iter().enumerate() {
630        if i > 0 && (digits.len() - i) % 3 == 0 {
631            grouped.push(',');
632        }
633        grouped.push(*ch);
634    }
635    format!("{}{grouped}.{frac_part}", if negative { "-" } else { "" })
636}
637
638/// features.md #4 — register the `sanitize` filter: clean a string of
639/// HTML (e.g. the output of the admin's RTE widget, which stores
640/// HTML rather than markdown) down to ammonia's safe allowlist and
641/// hand it to the template as a safe string.
642///
643/// `{{ body | sanitize }}` is the display companion to the `rte`
644/// widget the way `{{ body | markdown }}` is to the `markdown` widget:
645/// the stored value is HTML, so it's sanitized — never trusted — before
646/// it reaches the page. A value tampered with via the REST write path
647/// (which doesn't go through the editor) is made safe here.
648fn register_sanitize_filter(env: &mut Environment<'static>) {
649    env.add_filter("sanitize", |input: String| -> minijinja::Value {
650        minijinja::Value::from_safe_string(sanitize_html(&input))
651    });
652}
653
654/// Clean `input` HTML down to ammonia's safe allowlist (strips
655/// `<script>`, event handlers, `javascript:` URLs, etc.). The
656/// non-markdown sibling of [`render_markdown`] — use it on stored HTML
657/// (the RTE widget's output).
658pub fn sanitize_html(input: &str) -> String {
659    ammonia::clean(input)
660}
661
662/// The class prefix syntect token spans carry (`hl-keyword`, `hl-string`,
663/// `hl-source`, …). Shared by the highlighter and the generated
664/// stylesheet so the two never drift.
665const HL_PREFIX: &str = "hl-";
666
667fn hl_class_style() -> ClassStyle {
668    ClassStyle::SpacedPrefixed { prefix: HL_PREFIX }
669}
670
671/// The bundled syntect syntax set, loaded once. The load parses a binary
672/// dump and is expensive, so it is cached for the life of the process.
673fn syntax_set() -> &'static SyntaxSet {
674    static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
675    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
676}
677
678/// The `base16-ocean.dark` token stylesheet, generated once from syntect's
679/// bundled theme with the `hl-` class prefix. This is the single source of
680/// truth for token colors — the markdown highlighter emits matching
681/// classes. Returns `""` only if syntect cannot generate the CSS (it
682/// always can for a bundled theme), so callers never need to handle an
683/// error.
684pub fn highlight_css() -> &'static str {
685    static HIGHLIGHT_CSS: OnceLock<String> = OnceLock::new();
686    HIGHLIGHT_CSS
687        .get_or_init(|| {
688            let themes = ThemeSet::load_defaults();
689            match themes.themes.get("base16-ocean.dark") {
690                Some(theme) => {
691                    css_for_theme_with_class_style(theme, hl_class_style()).unwrap_or_default()
692                }
693                None => String::new(),
694            }
695        })
696        .as_str()
697}
698
699/// Return `true` iff every character in a fence info token is safe to
700/// embed verbatim in a `class="language-…"` HTML attribute value.
701///
702/// A legitimate language token is just a word: `rust`, `c++`, `c#`,
703/// `shell`, `text/plain`, etc. It never needs `<`, `>`, `"`, `'`, `=`,
704/// backticks, or whitespace. Rejecting those characters closes the
705/// class-injection vector that would otherwise let a hostile fence like
706/// `` ```<script>alert(1)</script> `` survive ammonia's pass (ammonia
707/// allows `class` on `<code>` but does not filter the attribute VALUE).
708fn fence_lang_is_safe(lang: &str) -> bool {
709    !lang.is_empty()
710        && lang.len() <= 64
711        && lang.chars().all(|c| {
712            c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '_' | '.' | '#' | '/' | '@')
713        })
714}
715
716/// Render one fenced code block to safe HTML. `lang` is the fence info
717/// token (`Some("rust")`) or `None` for an unlabelled / indented block.
718/// With a known language the body is syntect-highlighted into `hl-` token
719/// spans; otherwise — or on any highlighter error — it falls back to a
720/// plain escaped block that still carries `class="language-…"` so the
721/// `md-enhance.js` label keeps working. Never panics, never drops the
722/// user's code.
723fn highlight_code_block(lang: Option<&str>, src: &str) -> String {
724    // Validate the lang token before touching it. A hostile fence info
725    // string (e.g. `<script>alert(1)</script>`) must never land in the
726    // `class="language-…"` attribute value even after HTML-escaping,
727    // because ammonia re-parses the tree and may not re-escape `<`/`>`
728    // that appear inside attribute values of allowed elements. Treating
729    // an unsafe token as `None` produces a plain unlabelled code block
730    // (still safe and still readable) rather than a class-injection path.
731    let lang = lang.filter(|l| fence_lang_is_safe(l));
732
733    let ss = syntax_set();
734    let syntax = lang.and_then(|l| {
735        ss.find_syntax_by_token(l)
736            .or_else(|| ss.find_syntax_by_extension(l))
737    });
738    if let Some(syntax) = syntax {
739        let mut generator =
740            ClassedHTMLGenerator::new_with_class_style(syntax, ss, hl_class_style());
741        let mut ok = true;
742        for line in LinesWithEndings::from(src) {
743            if generator
744                .parse_html_for_line_which_includes_newline(line)
745                .is_err()
746            {
747                ok = false;
748                break;
749            }
750        }
751        if ok {
752            // `finalize()` returns safe `<span class="hl-…">` markup —
753            // pass it through unescaped.
754            return wrap_code_block(lang, &generator.finalize());
755        }
756    }
757    // Fallback: escape the raw text so it is inert, then wrap.
758    let mut escaped = String::with_capacity(src.len());
759    html_escape_into(&mut escaped, src);
760    wrap_code_block(lang, &escaped)
761}
762
763/// Wrap inner code HTML (token spans, or escaped plain text) in
764/// `<pre><code class="language-…">` so the md-enhance frame + language
765/// label attach. The language token is HTML-escaped before it lands in the
766/// class value (it comes straight from the fence info string).
767fn wrap_code_block(lang: Option<&str>, inner: &str) -> String {
768    let mut out = String::with_capacity(inner.len() + 48);
769    out.push_str("<pre><code");
770    if let Some(l) = lang {
771        out.push_str(" class=\"language-");
772        html_escape_into(&mut out, l);
773        out.push('"');
774    }
775    out.push('>');
776    out.push_str(inner);
777    out.push_str("</code></pre>");
778    out
779}
780
781/// Render CommonMark + GFM `input` to sanitized HTML. Pulled out of the
782/// filter closure so it's unit-testable and reusable by any future
783/// Rust-side caller (e.g. a REST endpoint that returns pre-rendered
784/// HTML).
785pub fn render_markdown(input: &str) -> String {
786    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html};
787
788    let mut options = Options::empty();
789    options.insert(Options::ENABLE_TABLES);
790    options.insert(Options::ENABLE_STRIKETHROUGH);
791    options.insert(Options::ENABLE_TASKLISTS);
792    options.insert(Options::ENABLE_FOOTNOTES);
793
794    let parser = Parser::new_ext(input, options);
795
796    // Rewrite the event stream: replace each code block with a single
797    // pre-highlighted Html event. The fence info token selects the syntect
798    // syntax; everything else passes through unchanged.
799    let mut events: Vec<Event> = Vec::new();
800    let mut in_code = false;
801    let mut code_lang: Option<String> = None;
802    let mut code_buf = String::new();
803    for event in parser {
804        match event {
805            Event::Start(Tag::CodeBlock(kind)) => {
806                in_code = true;
807                code_buf.clear();
808                code_lang = match kind {
809                    CodeBlockKind::Fenced(info) => {
810                        info.split_whitespace().next().map(str::to_string)
811                    }
812                    CodeBlockKind::Indented => None,
813                };
814            }
815            Event::End(TagEnd::CodeBlock) => {
816                in_code = false;
817                let highlighted = highlight_code_block(code_lang.as_deref(), &code_buf);
818                events.push(Event::Html(highlighted.into()));
819            }
820            Event::Text(text) if in_code => code_buf.push_str(&text),
821            other => events.push(other),
822        }
823    }
824
825    let mut rendered = String::new();
826    html::push_html(&mut rendered, events.into_iter());
827
828    // Sanitize. `pre`/`code`/`span` are already default-allowed tags, so we
829    // widen the allowlist by exactly one inert attribute — `class` on those
830    // three — letting syntect's `hl-` token spans and the `language-*` label
831    // survive. style / on* handlers / javascript: URLs stay stripped: this is
832    // the whole "safely" surface. Built per call: ammonia::Builder isn't Sync
833    // (boxed attribute_filter), so it can't be a shared static without a Mutex
834    // that would serialize rendering; this costs the same as ammonia::clean.
835    let mut cleaner = ammonia::Builder::default();
836    cleaner.add_tag_attributes("pre", &["class"]);
837    cleaner.add_tag_attributes("code", &["class"]);
838    cleaner.add_tag_attributes("span", &["class"]);
839    cleaner.clean(&rendered).to_string()
840}
841
842/// Tiny HTML attribute-value escape — covers the four characters
843/// that can break out of a double-quoted attribute context.
844/// Centralised here because the framework doesn't otherwise need
845/// to ship an html_escape crate dep just for the img filter.
846fn html_escape_into(out: &mut String, s: &str) {
847    for ch in s.chars() {
848        match ch {
849            '&' => out.push_str("&amp;"),
850            '<' => out.push_str("&lt;"),
851            '>' => out.push_str("&gt;"),
852            '"' => out.push_str("&quot;"),
853            '\'' => out.push_str("&#39;"),
854            c => out.push(c),
855        }
856    }
857}
858
859fn register_default_templates(
860    env: &mut Environment<'static>,
861    seen: &mut std::collections::HashSet<String>,
862) {
863    let entries = [
864        (
865            crate::errors::DEFAULT_404_TEMPLATE_NAME,
866            crate::errors::DEFAULT_404_HTML,
867        ),
868        (
869            crate::errors::DEFAULT_500_TEMPLATE_NAME,
870            crate::errors::DEFAULT_500_HTML,
871        ),
872    ];
873    for (name, source) in entries {
874        if seen.contains(name) {
875            continue; // already provided by user — skip
876        }
877        // These are compile-time constants so they're `&'static str`; we can
878        // add them without cloning via `add_template` (non-owned variant).
879        if env.add_template(name, source).is_ok() {
880            seen.insert(name.to_string());
881        }
882    }
883}
884
885/// Publish the template engine into the process-wide ambient handle.
886///
887/// `dirs` is the ordered list of directories to search — the first
888/// entry is searched first (highest priority). Typically this is:
889/// `[app_templates_dir, plugin_a_dir, plugin_b_dir, ...]`.
890///
891/// For each directory in order, every `.html` / `.htm` / `.txt` file is
892/// registered under its path-relative-to-that-dir name. If a name was
893/// already registered by an earlier directory, the later file is skipped
894/// and a `tracing::warn!` is emitted so the collision is visible.
895///
896/// If none of the directories exist, init succeeds with an empty engine.
897/// This is the right default for binaries that don't render HTML.
898///
899/// Returns the list of template names that collided (appeared in more
900/// than one directory). The caller (`App::build`) logs these via tracing.
901/// Tests can inspect the returned list to assert collision detection
902/// without needing a tracing subscriber.
903pub fn init(dirs: &[PathBuf]) -> Result<Vec<String>, TemplateError> {
904    let (env, collisions) = build_env(dirs)?;
905
906    for name in &collisions {
907        tracing::warn!(
908            template = %name,
909            "umbral templates: template `{name}` is provided by multiple directories; \
910             the first-registered copy wins"
911        );
912    }
913
914    // Stash the dirs so the dev-mode render path can rebuild the env
915    // on demand without re-running the (more expensive) init flow.
916    let _ = WATCHED_DIRS.set(dirs.to_vec());
917
918    ENGINE
919        .set(env)
920        .map_err(|_| TemplateError::AlreadyInitialised)?;
921    Ok(collisions)
922}
923
924/// Like [`init`], but also installs plugin-contributed
925/// [`TemplateRegistrar`]s (feature #67). The registrars are stashed in
926/// the process-wide [`REGISTRARS`] handle *before* the engine is built so
927/// [`build_env`] applies them — both here and on every dev-mode rebuild.
928///
929/// Called by `App::build` with the flattened registrars from every
930/// plugin's `template_registrars()`, in topological order. The plain
931/// [`init`] stays the no-plugin entry point used by template unit tests.
932pub fn init_with(
933    dirs: &[PathBuf],
934    registrars: Vec<TemplateRegistrar>,
935) -> Result<Vec<String>, TemplateError> {
936    // Set even when empty so a second (errant) init can't smuggle in a
937    // different registrar set behind the already-published engine.
938    let _ = REGISTRARS.set(registrars);
939    init(dirs)
940}
941
942/// Build a fresh `Environment` from the given dirs. Shared by the
943/// init path and the dev-mode hot-reload path; both produce
944/// bit-identical engines from the same input.
945fn build_env(dirs: &[PathBuf]) -> Result<(Environment<'static>, Vec<String>), TemplateError> {
946    let mut env = Environment::new();
947    // Autoescape extensions MUST stay in sync with the loader
948    // whitelist in `load_directory` (currently `html | htm | txt`).
949    // If you add `.svg` or `.xml` to the loader, add them HERE too
950    // — `.svg` carries inline-script XSS risk and `.xml` is generally
951    // parsed by something downstream that wants attribute escaping.
952    // `.txt` stays `None` because plaintext rendering shouldn't HTML-
953    // escape (would replace `<` with `&lt;` in plain email bodies).
954    env.set_auto_escape_callback(|name| {
955        if name.ends_with(".html") || name.ends_with(".htm") {
956            AutoEscape::Html
957        } else {
958            AutoEscape::None
959        }
960    });
961
962    // gaps2 #21 — register the `img` filter for ergonomic, perf-
963    // forward image markup. `{{ url | img(alt="...", width=400,
964    // height=300) }}` expands to a fully-formed `<img>` with the
965    // hat-trick that catches LCP regressions out of the box:
966    // `loading="lazy"`, `decoding="async"`, explicit `width`/
967    // `height` to reserve layout space (no CLS), and an `alt`
968    // attribute that's empty rather than omitted (screen-reader-
969    // friendly default for purely decorative images). Optional
970    // `class="..."` flows through for Tailwind / scoped styling.
971    register_img_filter(&mut env);
972
973    // `{{ highlight_styles() }}` — the syntect token stylesheet for
974    // server-highlighted code, emitted once into <head> by a base template.
975    register_highlight_styles_function(&mut env);
976
977    // Unified static pipeline — `{{ static("admin/admin.css") }}`
978    // expands to `<static_url>admin/admin.css`. The `static_url` is read
979    // from ambient settings (defaulting to `/static/` when settings
980    // aren't initialised yet, e.g. in a bare template unit test) and
981    // captured into the function closure. See `register_static_function`.
982    let static_url = crate::settings::get_opt()
983        .map(|s| s.static_url.clone())
984        .unwrap_or_else(|| "/static/".to_string());
985    register_static_function(&mut env, static_url);
986
987    // `{{ media_url(plugin.logo) }}` resolves a stored file/image KEY
988    // through the ambient Storage backend's `url()`, the media-side
989    // companion to `static()`. ImageField/FileField serialize as the
990    // bare key; this turns it into the public URL. See
991    // `register_media_url_function`.
992    register_media_url_function(&mut env);
993
994    // features.md #4 — `{{ body | markdown }}` renders user-supplied
995    // CommonMark/GFM to sanitized HTML. The reusable "safely show a
996    // body/usage field" surface shared by the admin and end-user
997    // templates; pairs with `#[umbral(widget = "markdown")]` on the
998    // model field that captures the source.
999    register_markdown_filter(&mut env);
1000
1001    // features.md #4 — `{{ html | sanitize }}` cleans stored HTML (the
1002    // `rte` admin widget's output) to a safe allowlist. The HTML-side
1003    // companion to the markdown filter.
1004    register_sanitize_filter(&mut env);
1005
1006    // gaps2 #19 follow-up — render `None` / `Undefined` as the
1007    // empty string instead of the literal "none" / "undefined" tokens
1008    // MiniJinja defaults to. Bug screenshot 2026-06-10 01-08-30: an
1009    // `Option<String>` model field with `value=None` rendered into
1010    // `<input value="{{ form.phone }}">` produced `value="none"` on a
1011    // fresh form, which the user then has to manually clear before
1012    // typing. Every form with optional fields hit this footgun.
1013    //
1014    // Defining a custom formatter is the framework-level fix — every
1015    // template (admin, shop, plugins) inherits the new behaviour
1016    // automatically. Non-null/non-undefined values pass through the
1017    // default formatter unchanged so HTML escaping, number / bool /
1018    // string rendering, and safe-string handling stay identical.
1019    env.set_formatter(|out, state, value| {
1020        if value.is_none() || value.is_undefined() {
1021            return Ok(());
1022        }
1023        minijinja::escape_formatter(out, state, value)
1024    });
1025
1026    // features.md #67 — built-in example tags/filters. These ship as the
1027    // reference implementations for the custom-tag surface: `now()` for a
1028    // server-rendered timestamp, `currency` for money formatting. Plugins
1029    // add their own via `Plugin::template_registrars` (applied below).
1030    register_now_function(&mut env);
1031    register_currency_filter(&mut env);
1032
1033    // features #65 — `{{ querystring_with(base_query, "page", item.n) }}`
1034    // rebuilds the current querystring replacing one key, so the bundled
1035    // `_pagination.html` nav carries `?sort=...` filters across every
1036    // `?page=N` link. See `register_querystring_with_function`.
1037    register_querystring_with_function(&mut env);
1038
1039    // features.md #67 — plugin-contributed filters/functions. Applied
1040    // AFTER the built-ins so a plugin can deliberately override one by
1041    // re-registering the same name (minijinja's add_* overwrites). Runs
1042    // on every rebuild (dev hot-reload) because `Fn`, not `FnOnce`.
1043    if let Some(registrars) = REGISTRARS.get() {
1044        for registrar in registrars {
1045            registrar(&mut env);
1046        }
1047    }
1048
1049    let mut seen: HashSet<String> = HashSet::new();
1050    let mut collisions: Vec<String> = Vec::new();
1051
1052    // Register the built-in default error templates before scanning disk
1053    // directories. Because disk directories are first-match-wins and are
1054    // scanned after this call, a user template with the same name (unlikely,
1055    // since the `__umbral__/` prefix is reserved) would silently replace the
1056    // built-in. Callers who want a clean opt-out should use
1057    // `App::builder().disable_default_error_pages()`.
1058    register_default_templates(&mut env, &mut seen);
1059
1060    for dir in dirs {
1061        if dir.exists() {
1062            load_directory(&mut env, dir, dir, &mut seen, &mut collisions)?;
1063        }
1064    }
1065
1066    Ok((env, collisions))
1067}
1068
1069/// Render a template by name with a serde-serializable context value.
1070///
1071/// The name is the path relative to its templates directory, with
1072/// forward slashes regardless of host OS. `articles_list.html`,
1073/// `admin/base.html`, etc.
1074///
1075/// Returns `TemplateError::NotInitialised` if `App::build()` hasn't
1076/// run yet, `TemplateError::Missing` if the name doesn't match a
1077/// loaded template, and `TemplateError::Render` for any minijinja-
1078/// reported issue (syntax error, missing variable when strict undefined
1079/// is on, etc.).
1080pub fn render<C: Serialize>(name: &str, ctx: &C) -> Result<String, TemplateError> {
1081    // Dev-mode hot reload: when settings.environment == Dev, rebuild
1082    // the environment from disk on every render so template edits are
1083    // picked up without a server restart. This makes the dev loop —
1084    // edit `home.html`, hit reload, see the change — work without
1085    // `cargo run`-ing again. Production stays on the cached engine
1086    // for the fast path.
1087    //
1088    // Cost: one disk walk + minijinja parse per render in dev. For a
1089    // typical handler doing one render per request at ~10 RPS during
1090    // development, that's negligible. We chose this over per-file
1091    // stat checks because the per-render rebuild is dependency-free
1092    // and the staleness window is zero (a save followed instantly
1093    // by a reload always sees the new content).
1094    if dev_mode_active() {
1095        if let Some(dirs) = WATCHED_DIRS.get() {
1096            // Rebuild fresh; ignore collisions log here (init already
1097            // logged them once; we don't spam every render).
1098            match build_env(dirs) {
1099                Ok((env, _collisions)) => return render_with(&env, name, ctx),
1100                Err(e) => return Err(e),
1101            }
1102        }
1103    }
1104
1105    let env = ENGINE.get().ok_or(TemplateError::NotInitialised)?;
1106    render_with(env, name, ctx)
1107}
1108
1109/// Render an inline template source through the ambient-context path.
1110/// Test/bench helper only.
1111///
1112/// SECURITY (audit_2 core-templates-forms #3): this builds a fresh
1113/// `Environment`, whose minijinja default is `AutoEscape::None`. A no-escape
1114/// inline renderer that's `pub` (even `#[doc(hidden)]`) is an SSTI/XSS foot-gun
1115/// the moment any caller feeds it user data. We force `AutoEscape::Html` so
1116/// `{{ x }}` escapes exactly like a `.html` template rendered through
1117/// [`build_env`]; a caller that genuinely wants raw output opts in per value
1118/// with minijinja's `| safe`.
1119#[doc(hidden)]
1120pub fn render_str<C: Serialize>(src: &str, ctx: &C) -> Result<String, TemplateError> {
1121    let mut env = minijinja::Environment::new();
1122    env.set_auto_escape_callback(|_| AutoEscape::Html);
1123    env.add_template("__inline", src)
1124        .map_err(TemplateError::Render)?;
1125    render_with(&env, "__inline", ctx)
1126}
1127
1128/// True when the ambient settings say we're in Dev. Returns false if
1129/// settings haven't been initialised (production-style binaries that
1130/// never went through `App::build()`).
1131fn dev_mode_active() -> bool {
1132    crate::settings::get_opt()
1133        .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
1134        .unwrap_or(false)
1135}
1136
1137/// Render a named template against the given env. Extracted so dev-mode
1138/// (fresh env per render) and prod (cached env) share one error mapping.
1139fn render_with<C: Serialize>(
1140    env: &Environment<'_>,
1141    name: &str,
1142    ctx: &C,
1143) -> Result<String, TemplateError> {
1144    let tmpl = env.get_template(name).map_err(|e| match e.kind() {
1145        minijinja::ErrorKind::TemplateNotFound => TemplateError::Missing(name.to_string()),
1146        _ => TemplateError::Render(e),
1147    })?;
1148    let merged = merge_ambient_context(ctx);
1149    tmpl.render(&merged).map_err(TemplateError::Render)
1150}
1151
1152/// Merge the ambient task-locals into a serializable template context:
1153/// `user` (from `CURRENT_USER`) and the CSRF pair `csrf_token` /
1154/// `csrf_input` (from `CURRENT_CSRF`). The handler's own keys always
1155/// win — the ambient injection is the default, not an override.
1156///
1157/// `user` is injected unconditionally (anonymous fallback below);
1158/// the CSRF pair only when a middleware actually scoped a token —
1159/// there is no meaningful fallback token, and rendering an empty
1160/// hidden input would make a form post a guaranteed-403 silently.
1161///
1162/// Most code should use [`render`], which calls this automatically.
1163/// Plugins that own a private MiniJinja environment can call this before
1164/// `Template::render` to get the same `{{ user }}`, `{{ csrf_token }}`,
1165/// and `{{ csrf_input }}` semantics as the framework renderer.
1166pub fn merge_ambient_context<C: Serialize>(ctx: &C) -> minijinja::Value {
1167    let ctx_value = minijinja::Value::from_serialize(ctx);
1168    merge_ambient_value(ctx_value)
1169}
1170
1171/// Same as [`merge_ambient_context`], but accepts an already-built
1172/// MiniJinja [`Value`](minijinja::Value). This is useful for private
1173/// plugin renderers that build context with `minijinja::context!`.
1174pub fn merge_ambient_value(ctx_value: minijinja::Value) -> minijinja::Value {
1175    let has = |key: &str| {
1176        ctx_value
1177            .get_attr(key)
1178            .map(|v| !v.is_undefined())
1179            .unwrap_or(false)
1180    };
1181
1182    let need_user = !has("user");
1183    let csrf = current_csrf();
1184    let need_csrf = csrf.is_some() && !(has("csrf_token") && has("csrf_input"));
1185
1186    if !need_user && !need_csrf {
1187        return ctx_value;
1188    }
1189
1190    // Build a fresh object that contains every original key plus the
1191    // ambient ones. minijinja's `Value::from_iter` over (key, value)
1192    // pairs produces a Map value; we walk the original keys and add
1193    // ours last.
1194    let mut pairs: Vec<(String, minijinja::Value)> = Vec::new();
1195    if let Ok(keys) = ctx_value.try_iter() {
1196        for key in keys {
1197            let key_str = key.to_string();
1198            if let Ok(v) = ctx_value.get_item(&key) {
1199                pairs.push((key_str, v));
1200            }
1201        }
1202    }
1203
1204    if need_user {
1205        // Resolve which `user` value should land in the rendered ctx:
1206        //   1. Task-local set by a middleware (AuthPlugin's
1207        //      `user_context_layer`) — the live request shape.
1208        //   2. Anonymous fallback `{ is_authenticated: false }` for
1209        //      callers WITHOUT a layer mounted AND for renders that
1210        //      happen outside the middleware's scope (notably the
1211        //      `render_500_middleware` recovery path — the
1212        //      user-context task-local has already dropped by the time
1213        //      the error layer renders, but the 500 template still
1214        //      needs `user.is_authenticated` to evaluate cleanly).
1215        //
1216        // The fallback is the same shape `serialize_anonymous` would
1217        // produce, kept in core so umbral-auth isn't a dependency of
1218        // the templates module.
1219        // Prefer the lazy channel (proxy defers resolution until attribute access),
1220        // then the eager task-local, then the anonymous fallback.
1221        let user_value = if let Ok(lazy) = CURRENT_USER_LAZY.try_with(|lazy| lazy.clone()) {
1222            lazy.into_proxy_value()
1223        } else if let Some(v) = CURRENT_USER.try_with(|u| u.clone()).ok().flatten() {
1224            v
1225        } else {
1226            anonymous_user_value()
1227        };
1228        pairs.push(("user".to_string(), user_value));
1229    }
1230
1231    if let Some(token) = csrf {
1232        if !has("csrf_token") {
1233            pairs.push((
1234                "csrf_token".to_string(),
1235                minijinja::Value::from(token.clone()),
1236            ));
1237        }
1238        if !has("csrf_input") {
1239            // Today's tokens are hex (signed mode adds `.` + hex sig),
1240            // so the escape is belt-and-braces against a future
1241            // token-shape change — not a live attack surface.
1242            let escaped = token
1243                .replace('&', "&amp;")
1244                .replace('"', "&quot;")
1245                .replace('<', "&lt;")
1246                .replace('>', "&gt;");
1247            pairs.push((
1248                "csrf_input".to_string(),
1249                minijinja::Value::from_safe_string(format!(
1250                    r#"<input type="hidden" name="csrf_token" value="{escaped}">"#
1251                )),
1252            ));
1253        }
1254    }
1255
1256    minijinja::Value::from_iter(pairs)
1257}
1258
1259/// Anonymous-user sentinel — the value `user` resolves to in
1260/// templates rendered outside an authenticated context (no auth
1261/// middleware, anonymous request, or the 500-rendering path
1262/// where the middleware's task-local has already dropped).
1263/// Carries only `{ is_authenticated: false }` — enough for
1264/// `{% if user.is_authenticated %}` / `{% if user.is_staff %}`
1265/// to evaluate to false without `umbral templates: undefined
1266/// value` errors that would otherwise mask the original failure.
1267fn anonymous_user_value() -> minijinja::Value {
1268    let mut map = serde_json::Map::new();
1269    map.insert(
1270        "is_authenticated".to_string(),
1271        serde_json::Value::Bool(false),
1272    );
1273    // is_staff / is_superuser default to false too so a template
1274    // gating on either doesn't accidentally render the privileged
1275    // branch when `user` is the anonymous fallback.
1276    map.insert("is_staff".to_string(), serde_json::Value::Bool(false));
1277    map.insert("is_superuser".to_string(), serde_json::Value::Bool(false));
1278    minijinja::Value::from_serialize(serde_json::Value::Object(map))
1279}
1280
1281/// Walk a directory recursively and register every `.html` / `.htm` /
1282/// `.txt` file as a template under its path-relative-to-root name.
1283/// Subdirectories are reachable via forward-slash names: `admin/base.html`.
1284///
1285/// `seen` tracks which names have already been registered across all
1286/// directories. When a name collision is detected (a later directory
1287/// ships a template with the same relative name as an earlier one),
1288/// the duplicate is skipped and the name is appended to `collisions`.
1289/// First-match-wins.
1290fn load_directory(
1291    env: &mut Environment<'static>,
1292    root: &Path,
1293    dir: &Path,
1294    seen: &mut HashSet<String>,
1295    collisions: &mut Vec<String>,
1296) -> Result<(), TemplateError> {
1297    for entry in std::fs::read_dir(dir)? {
1298        let entry = entry?;
1299        let path = entry.path();
1300        if path.is_dir() {
1301            load_directory(env, root, &path, seen, collisions)?;
1302            continue;
1303        }
1304        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
1305            continue;
1306        };
1307        if !matches!(ext, "html" | "htm" | "txt") {
1308            continue;
1309        }
1310        let rel: PathBuf = path
1311            .strip_prefix(root)
1312            .expect("walked path is rooted at the templates dir")
1313            .to_path_buf();
1314        // minijinja template names are forward-slashed regardless of OS;
1315        // the path display would emit `\` on Windows, so build the name
1316        // explicitly.
1317        let name: String = rel
1318            .components()
1319            .map(|c| c.as_os_str().to_string_lossy().to_string())
1320            .collect::<Vec<_>>()
1321            .join("/");
1322
1323        if seen.contains(&name) {
1324            // Collision: a higher-priority directory already registered
1325            // this name. Record it and skip; init will log after all
1326            // dirs are processed.
1327            if !collisions.contains(&name) {
1328                collisions.push(name.clone());
1329            }
1330            continue;
1331        }
1332
1333        let source = std::fs::read_to_string(&path)?;
1334        env.add_template_owned(name.clone(), source)
1335            .map_err(TemplateError::Render)?;
1336        seen.insert(name);
1337    }
1338    Ok(())
1339}
1340
1341/// Errors the template engine can produce. Narrow at v1: load-time IO,
1342/// engine-not-ready, missing template, render-time minijinja error.
1343#[derive(Debug)]
1344pub enum TemplateError {
1345    /// `App::build()` hasn't run yet, so the ambient engine isn't set.
1346    NotInitialised,
1347    /// `init` was called twice — a programming error in the framework
1348    /// itself, not the user. Surfaced as a `BuildError` if it ever fires.
1349    AlreadyInitialised,
1350    /// IO error reading a template file at boot.
1351    Io(std::io::Error),
1352    /// The requested template name isn't loaded.
1353    Missing(String),
1354    /// Any other minijinja error (syntax, render-time, etc.). The
1355    /// inner `minijinja::Error` carries the diagnostic (line / col /
1356    /// undefined name) so the caller can pass it through `Display`.
1357    Render(minijinja::Error),
1358}
1359
1360impl std::fmt::Display for TemplateError {
1361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1362        match self {
1363            TemplateError::NotInitialised => write!(
1364                f,
1365                "umbral templates: engine not initialised — call App::build() first"
1366            ),
1367            TemplateError::AlreadyInitialised => {
1368                write!(f, "umbral templates: init called more than once")
1369            }
1370            TemplateError::Io(e) => write!(f, "umbral templates: io: {e}"),
1371            TemplateError::Missing(name) => write!(
1372                f,
1373                "umbral templates: no template named `{name}`; check the templates directory"
1374            ),
1375            TemplateError::Render(e) => write!(f, "umbral templates: {e}"),
1376        }
1377    }
1378}
1379
1380impl std::error::Error for TemplateError {}
1381
1382impl From<std::io::Error> for TemplateError {
1383    fn from(e: std::io::Error) -> Self {
1384        Self::Io(e)
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use serde_json::json;
1392
1393    #[test]
1394    fn img_url_scheme_safety() {
1395        // Relative + http(s) are allowed.
1396        assert!(url_scheme_is_safe("/media/cat.png"));
1397        assert!(url_scheme_is_safe("cat.png"));
1398        assert!(url_scheme_is_safe("../up/cat.png"));
1399        assert!(url_scheme_is_safe("http://example.com/cat.png"));
1400        assert!(url_scheme_is_safe("https://example.com/cat.png"));
1401        assert!(url_scheme_is_safe("HTTPS://EXAMPLE.com/cat.png"));
1402        assert!(url_scheme_is_safe("?query=only"));
1403        assert!(url_scheme_is_safe("#fragment"));
1404        // Dangerous / non-http schemes are rejected.
1405        assert!(!url_scheme_is_safe("javascript:alert(1)"));
1406        assert!(!url_scheme_is_safe("  javascript:alert(1)"));
1407        assert!(!url_scheme_is_safe("JaVaScRiPt:alert(1)"));
1408        assert!(!url_scheme_is_safe(
1409            "data:text/html,<script>alert(1)</script>"
1410        ));
1411        assert!(!url_scheme_is_safe("vbscript:msgbox(1)"));
1412        assert!(!url_scheme_is_safe("mailto:a@b.com"));
1413        // Malformed scheme (embedded control char) fails closed.
1414        assert!(!url_scheme_is_safe("java\u{0}script:alert(1)"));
1415    }
1416
1417    #[test]
1418    fn img_filter_neutralises_javascript_url() {
1419        let mut env = minijinja::Environment::new();
1420        register_img_filter(&mut env);
1421        env.add_template("t", "{{ url | img }}").unwrap();
1422        let tmpl = env.get_template("t").unwrap();
1423        let out = tmpl
1424            .render(minijinja::context! { url => "javascript:alert(1)" })
1425            .unwrap();
1426        assert!(
1427            !out.contains("javascript:"),
1428            "javascript: URL must be neutralised; got {out}"
1429        );
1430        assert!(out.contains("src=\"\""), "expected empty src; got {out}");
1431    }
1432
1433    #[test]
1434    fn nested_template_names_are_relative_to_templates_root() {
1435        let tmp = tempfile::tempdir().expect("create temp dir");
1436        let templates = tmp.path().join("templates");
1437        std::fs::create_dir_all(templates.join("base")).expect("create base template dir");
1438        std::fs::create_dir_all(templates.join("content")).expect("create content template dir");
1439
1440        std::fs::write(
1441            templates.join("base").join("site.html"),
1442            "<main>{% block content %}{% endblock %}</main>",
1443        )
1444        .expect("write nested base template");
1445        std::fs::write(
1446            templates.join("content").join("contact.html"),
1447            r#"{% extends "base/site.html" %}{% block content %}<h1>{{ title }}</h1><p>Contact from nested content.</p>{% endblock %}"#,
1448        )
1449        .expect("write nested content template");
1450
1451        let (env, collisions) = build_env(&[templates]).expect("build template env");
1452        assert!(collisions.is_empty());
1453
1454        let rendered = render_with(
1455            &env,
1456            "content/contact.html",
1457            &json!({ "title": "Nested contact" }),
1458        )
1459        .expect("render nested template by relative name");
1460
1461        assert!(rendered.contains("<main>"));
1462        assert!(rendered.contains("<h1>Nested contact</h1>"));
1463        assert!(rendered.contains("Contact from nested content."));
1464    }
1465
1466    /// Render `{{ static(arg) }}` against an env whose `static()` was
1467    /// registered with the given `static_url`. Exercises the helper
1468    /// directly without needing the ambient `Settings` OnceLock (which
1469    /// can't be set under cargo's parallel test runner).
1470    fn render_static(static_url: &str, arg: &str) -> String {
1471        let mut env = Environment::new();
1472        register_static_function(&mut env, static_url.to_string());
1473        env.add_template("t.txt", "{{ static(arg) }}")
1474            .expect("add template");
1475        let tmpl = env.get_template("t.txt").expect("get template");
1476        tmpl.render(json!({ "arg": arg })).expect("render")
1477    }
1478
1479    #[test]
1480    fn static_helper_prepends_root_relative_url() {
1481        assert_eq!(
1482            render_static("/static/", "admin/admin.css"),
1483            "/static/admin/admin.css"
1484        );
1485    }
1486
1487    #[test]
1488    fn static_helper_prepends_cdn_origin() {
1489        assert_eq!(
1490            render_static("https://cdn.example.com/s/", "admin/admin.css"),
1491            "https://cdn.example.com/s/admin/admin.css"
1492        );
1493    }
1494
1495    #[test]
1496    fn static_helper_does_not_double_slash_on_leading_slash_arg() {
1497        assert_eq!(render_static("/static/", "/admin/x"), "/static/admin/x");
1498    }
1499
1500    #[test]
1501    fn highlight_css_contains_hl_rules() {
1502        let css = highlight_css();
1503        assert!(!css.is_empty(), "generated theme CSS should not be empty");
1504        assert!(
1505            css.contains(".hl-"),
1506            "theme CSS must target hl- classes: {css}"
1507        );
1508    }
1509
1510    #[test]
1511    fn fenced_rust_block_gets_syntect_token_spans() {
1512        let html = render_markdown("```rust\nfn main() {}\n```\n");
1513        assert!(
1514            html.contains("language-rust"),
1515            "keeps the language class for the md-enhance label: {html}"
1516        );
1517        assert!(
1518            html.contains("class=\"hl-"),
1519            "emits syntect hl- token spans: {html}"
1520        );
1521    }
1522
1523    #[test]
1524    fn script_in_code_fence_is_escaped_not_executed() {
1525        let html = render_markdown("```\n<script>alert(1)</script>\n```\n");
1526        assert!(!html.contains("<script>"), "no live script tag: {html}");
1527        assert!(
1528            html.contains("&lt;script&gt;"),
1529            "rendered as inert text: {html}"
1530        );
1531    }
1532
1533    #[test]
1534    fn prose_script_is_still_stripped() {
1535        let html = render_markdown("hello <script>alert(1)</script> world");
1536        assert!(!html.contains("<script>"), "prose script stripped: {html}");
1537    }
1538
1539    #[test]
1540    fn markdown_allows_class_but_not_style() {
1541        let html = render_markdown("<span class=\"x\" style=\"color:red\">hi</span>");
1542        assert!(html.contains("class=\"x\""), "class survives: {html}");
1543        assert!(!html.contains("style="), "style stripped: {html}");
1544    }
1545
1546    #[test]
1547    fn unknown_and_plain_fences_do_not_panic() {
1548        let unknown = render_markdown("```notalanguage\nx := 1\n```\n");
1549        let plain = render_markdown("```\nplain text\n```\n");
1550        assert!(
1551            unknown.contains("<pre><code"),
1552            "unknown lang block: {unknown}"
1553        );
1554        assert!(plain.contains("<pre><code"), "plain block: {plain}");
1555        assert!(
1556            unknown.contains("language-notalanguage"),
1557            "unknown lang still labelled: {unknown}"
1558        );
1559    }
1560
1561    /// Security: a hostile fence info token (e.g. `<script>alert(1)</script>`)
1562    /// must NOT appear as a live tag in the output. `wrap_code_block` HTML-escapes
1563    /// the lang token before inserting it into the class attribute value, and
1564    /// ammonia's builder only permits `class` on `<code>` — it does not allow
1565    /// arbitrary attributes or values. So a `<script>` info string is inert.
1566    ///
1567    /// Also asserts that the SAFE path — a plain `language-rust` class on
1568    /// the `<code>` element — still survives after the widened allowlist so
1569    /// the syntect token spans have a hook. This is the regression pin for
1570    /// gaps2 #36 sub-part (a).
1571    #[test]
1572    fn hostile_fence_info_string_is_escaped_and_language_class_survives() {
1573        // Hostile: info token that looks like a script injection.
1574        let hostile = render_markdown("```<script>alert(1)</script>\ncode\n```\n");
1575        assert!(
1576            !hostile.contains("<script>"),
1577            "live <script> from fence info must be stripped: {hostile}"
1578        );
1579        // The escaped form will appear inside a class value; ammonia lets
1580        // class through but the content is HTML-escaped so it is inert.
1581        assert!(
1582            hostile.contains("<pre><code"),
1583            "code block structure must survive: {hostile}"
1584        );
1585
1586        // Hostile: info token with a class-injection attempt.
1587        let class_inject = render_markdown("```evil\" onmouseover=\"alert(1)\ncode\n```\n");
1588        assert!(
1589            !class_inject.contains("onmouseover"),
1590            "event handler injected via fence info must not survive: {class_inject}"
1591        );
1592
1593        // Safe: the normal case — language-rust class must survive so
1594        // syntect hl- spans (server-side) and the md-enhance label both work.
1595        let safe = render_markdown("```rust\nfn ok() {}\n```\n");
1596        assert!(
1597            safe.contains("language-rust"),
1598            "language-rust class must survive sanitization (gaps2 #36a): {safe}"
1599        );
1600        assert!(
1601            safe.contains("class=\"hl-"),
1602            "syntect hl- token spans must survive sanitization: {safe}"
1603        );
1604    }
1605
1606    #[test]
1607    fn highlight_styles_global_emits_a_style_block() {
1608        let mut env = Environment::new();
1609        register_highlight_styles_function(&mut env);
1610        env.add_template("t", "{{ highlight_styles() }}")
1611            .expect("add template");
1612        let out = env
1613            .get_template("t")
1614            .expect("get template")
1615            .render(())
1616            .expect("render");
1617        assert!(out.starts_with("<style>"), "wraps in a style block: {out}");
1618        assert!(out.contains(".hl-"), "carries the token CSS: {out}");
1619        assert!(
1620            out.trim_end().ends_with("</style>"),
1621            "closes the style block: {out}"
1622        );
1623    }
1624}