Skip to main content

renox_core/
view.rs

1use std::path::{Component, Path, PathBuf};
2use std::sync::Arc;
3
4use axum::body::Body;
5use axum::extract::{Request, State};
6use axum::http::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
7use axum::http::{HeaderValue, StatusCode};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Redirect, Response};
10use minijinja::value::{Rest, merge_maps};
11use minijinja::{Environment, ErrorKind, Value, context};
12use minijinja_autoreload::AutoReloader;
13use serde::Serialize;
14
15use crate::auth::CurrentUser;
16use crate::error::{ErrorPage, reason};
17use crate::storage::Storage;
18use crate::validation::ValidationError;
19use crate::{AppState, Config, Error, Htmx, RouteTable, Session, assets};
20
21/// Templates that ship with Renox. An app overrides one by creating a file
22/// with the same name in its views directory.
23const BUILTIN: &[(&str, &str)] = &[
24    ("renox/error.html", include_str!("../views/error.html")),
25    (
26        "renox/pagination.html",
27        include_str!("../views/pagination.html"),
28    ),
29    (
30        "renox/auth/layout.html",
31        include_str!("../views/auth/layout.html"),
32    ),
33    (
34        "renox/auth/login.html",
35        include_str!("../views/auth/login.html"),
36    ),
37    (
38        "renox/auth/register.html",
39        include_str!("../views/auth/register.html"),
40    ),
41    (
42        "renox/auth/forgot-password.html",
43        include_str!("../views/auth/forgot-password.html"),
44    ),
45    (
46        "renox/auth/reset-password.html",
47        include_str!("../views/auth/reset-password.html"),
48    ),
49    (
50        "renox/auth/verify-email.html",
51        include_str!("../views/auth/verify-email.html"),
52    ),
53    (
54        "renox/mail/layout.html",
55        include_str!("../views/mail/layout.html"),
56    ),
57    (
58        "renox/mail/button.html",
59        include_str!("../views/mail/button.html"),
60    ),
61    ("renox/ui.html", include_str!("../views/ui.html")),
62    (
63        "renox/import_report.html",
64        include_str!("../views/import_report.html"),
65    ),
66    ("renox/grid.html", include_str!("../views/grid.html")),
67    (
68        "renox/grid_print.html",
69        include_str!("../views/grid_print.html"),
70    ),
71    ("renox/debug.html", include_str!("../views/debug.html")),
72    (
73        "renox/notifications.html",
74        include_str!("../views/notifications.html"),
75    ),
76    (
77        "renox/queue/dashboard.html",
78        include_str!("../views/queue/dashboard.html"),
79    ),
80    (
81        "renox/mail/components.html",
82        include_str!("../views/mail/components.html"),
83    ),
84    (
85        "renox/auth/account.html",
86        include_str!("../views/auth/account.html"),
87    ),
88    (
89        "renox/auth/account_sections.html",
90        include_str!("../views/auth/account_sections.html"),
91    ),
92    (
93        "renox/auth/confirm-password.html",
94        include_str!("../views/auth/confirm-password.html"),
95    ),
96    (
97        "renox/mail/auth/reset-password.html",
98        include_str!("../views/mail/auth/reset-password.html"),
99    ),
100    (
101        "renox/mail/auth/reset-password.txt",
102        include_str!("../views/mail/auth/reset-password.txt"),
103    ),
104    (
105        "renox/mail/auth/verify-email.html",
106        include_str!("../views/mail/auth/verify-email.html"),
107    ),
108    (
109        "renox/mail/auth/verify-email.txt",
110        include_str!("../views/mail/auth/verify-email.txt"),
111    ),
112];
113
114/// The template engine (MiniJinja), reading from `VIEWS_PATH`.
115///
116/// Templates are reloaded when they change while `APP_DEBUG` is on.
117#[derive(Clone)]
118pub struct Views {
119    reloader: Arc<AutoReloader>,
120}
121
122/// What an `App::share` function knows about the request being rendered.
123#[non_exhaustive]
124#[derive(Clone)]
125pub struct ViewContext {
126    /// The application state.
127    pub state: AppState,
128    /// The logged-in user, if any.
129    pub user: Option<Arc<crate::auth::User>>,
130    /// The request's language, e.g. `en`.
131    pub locale: String,
132    /// The request's path, e.g. `/products`.
133    pub path: String,
134}
135
136pub(crate) type ShareFn = Arc<
137    dyn Fn(
138            ViewContext,
139        )
140            -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<Value>> + Send>>
141        + Send
142        + Sync,
143>;
144
145pub(crate) fn share_fn<F, Fut, T>(compute: F) -> ShareFn
146where
147    F: Fn(ViewContext) -> Fut + Send + Sync + 'static,
148    Fut: std::future::Future<Output = crate::Result<T>> + Send + 'static,
149    T: Serialize,
150{
151    let compute = Arc::new(compute);
152    Arc::new(move |ctx| {
153        let compute = compute.clone();
154        Box::pin(async move { Ok(Value::from_serialize(compute(ctx).await?)) })
155    })
156}
157
158/// Adds functions, filters or globals to the template environment; see
159/// `App::templates`.
160pub(crate) type TemplateHook = Arc<dyn Fn(&mut Environment<'static>) + Send + Sync>;
161
162impl Views {
163    /// `embedded`: templates compiled into the binary, used instead of
164    /// `VIEWS_PATH` when given.
165    pub(crate) fn new(
166        config: &Config,
167        routes: Arc<RouteTable>,
168        storage: Storage,
169        embedded: Option<&'static [(&'static str, &'static str)]>,
170        hooks: Arc<Vec<TemplateHook>>,
171        zone: crate::timezone::Zone,
172        versions: Arc<crate::embedded::AssetVersions>,
173    ) -> Self {
174        let dir = config.views_path.clone();
175        let watch = config.debug && embedded.is_none() && dir.is_dir();
176        let debug = config.debug;
177        let currency = config.currency.clone();
178        let reloader = AutoReloader::new(move |notifier| {
179            let mut env = Environment::new();
180            env.set_formatter(format_value);
181            // While developing, printing a misspelled variable is an error
182            // instead of an empty string (`{% if x %}` on a missing one is fine).
183            if debug {
184                env.set_undefined_behavior(minijinja::UndefinedBehavior::SemiStrict);
185            }
186            let loader_dir = dir.clone();
187            env.set_loader(move |name| load(&loader_dir, embedded, name));
188
189            let routes = routes.clone();
190            env.add_function(
191                "route",
192                move |name: String, params: Rest<Value>| -> Result<Value, minijinja::Error> {
193                    // `route('products.index', page=2)`: named arguments are
194                    // the query string (`?page=2`).
195                    let (query, params): (Vec<&Value>, Vec<&Value>) =
196                        params.iter().partition(|p| p.is_kwargs());
197                    let params: Vec<&dyn std::fmt::Display> = params
198                        .iter()
199                        .map(|p| *p as &dyn std::fmt::Display)
200                        .collect();
201                    // Percent-encoded, so safe to use in HTML without escaping `/`.
202                    let mut url = routes.url(&name, &params).map_err(|err| {
203                        minijinja::Error::new(ErrorKind::InvalidOperation, err.to_string())
204                    })?;
205                    if let Some(kwargs) = query.first() {
206                        let mut pairs = form_urlencoded::Serializer::new(String::new());
207                        for key in kwargs.try_iter()? {
208                            let value = kwargs.get_item(&key)?;
209                            if value.is_none() || value.is_undefined() {
210                                continue;
211                            }
212                            pairs.append_pair(&key.to_string(), &value.to_string());
213                        }
214                        let pairs = pairs.finish();
215                        if !pairs.is_empty() {
216                            url.push(if url.contains('?') { '&' } else { '?' });
217                            url.push_str(&pairs.replace('&', "&amp;"));
218                        }
219                    }
220                    Ok(Value::from_safe_string(url))
221                },
222            );
223            // The current URL's query string with `page` set to `page`, for
224            // pagination links that keep filters such as `?q=coffee`.
225            env.add_function("page_url", |state: &minijinja::State, page: u32| -> Value {
226                let query = state
227                    .lookup("request")
228                    .and_then(|request| request.get_attr("query").ok())
229                    .and_then(|query| query.as_str().map(str::to_owned))
230                    .unwrap_or_default();
231                let mut url = form_urlencoded::Serializer::new(String::new());
232                for (key, value) in form_urlencoded::parse(query.as_bytes()) {
233                    if key != "page" {
234                        url.append_pair(&key, &value);
235                    }
236                }
237                url.append_pair("page", &page.to_string());
238                Value::from(format!("?{}", url.finish()))
239            });
240            // The current URL's query string with some keys set (or removed
241            // with `none`), and `page` dropped: `query_with(period="7d")`
242            // for filters that keep the others.
243            env.add_function(
244                "query_with",
245                |state: &minijinja::State,
246                 kwargs: minijinja::value::Kwargs|
247                 -> Result<Value, minijinja::Error> {
248                    let query = state
249                        .lookup("request")
250                        .and_then(|request| request.get_attr("query").ok())
251                        .and_then(|query| query.as_str().map(str::to_owned))
252                        .unwrap_or_default();
253                    let keys: Vec<String> = kwargs.args().map(str::to_owned).collect();
254                    let mut url = form_urlencoded::Serializer::new(String::new());
255                    for (key, value) in form_urlencoded::parse(query.as_bytes()) {
256                        if key != "page" && !keys.iter().any(|k| *k == key) {
257                            url.append_pair(&key, &value);
258                        }
259                    }
260                    for key in &keys {
261                        let value: Value = kwargs.get(key)?;
262                        if !value.is_none() && !value.is_undefined() {
263                            url.append_pair(key, &value.to_string());
264                        }
265                    }
266                    kwargs.assert_all_used()?;
267                    Ok(Value::from(format!("?{}", url.finish())))
268                },
269            );
270            // The current URL's query string as hidden inputs, without `page`
271            // and the keys named: `query_fields("period", "from", "to")`, so
272            // a GET form keeps the page's other filters.
273            env.add_function(
274                "query_fields",
275                |state: &minijinja::State, except: minijinja::value::Rest<String>| -> Value {
276                    let query = state
277                        .lookup("request")
278                        .and_then(|request| request.get_attr("query").ok())
279                        .and_then(|query| query.as_str().map(str::to_owned))
280                        .unwrap_or_default();
281                    let mut html = String::new();
282                    for (key, value) in form_urlencoded::parse(query.as_bytes()) {
283                        if key != "page" && !except.iter().any(|k| *k == key) {
284                            html.push_str(&format!(
285                                r#"<input type="hidden" name="{}" value="{}">"#,
286                                crate::toast::escape(&key),
287                                crate::toast::escape(&value)
288                            ));
289                        }
290                    }
291                    Value::from_safe_string(html)
292                },
293            );
294            env.add_function("method_field", |method: String| {
295                let method: String = method.chars().filter(char::is_ascii_alphabetic).collect();
296                Value::from_safe_string(format!(
297                    "<input type=\"hidden\" name=\"{}\" value=\"{}\">",
298                    crate::method::METHOD_FIELD,
299                    method.to_ascii_uppercase()
300                ))
301            });
302            // `/app.css?v=1a2b3c4d`: a new URL whenever the file changes.
303            let versions = versions.clone();
304            env.add_function("asset", move |path: String| {
305                let mut url = String::from("/");
306                crate::routing::encode(&mut url, path.trim_start_matches('/'), true);
307                if let Some(version) = versions.version(&path) {
308                    url.push_str("?v=");
309                    url.push_str(&version);
310                }
311                Value::from_safe_string(url)
312            });
313            let storage = storage.clone();
314            env.add_function("storage_url", move |key: String| {
315                Value::from_safe_string(storage.url(&key))
316            });
317            // The request's values for components: `old`, `error`, `t`, `can`,
318            // `csrf_field`, `auth`, `request`, `flash`… work inside imported
319            // macros too, not only in the rendered template.
320            for name in REQUEST_GLOBALS {
321                env.add_global(*name, Value::from_object(RequestGlobal(name)));
322            }
323            crate::view_stack::register(&mut env);
324            env.add_function("renox_ui", |kwargs: minijinja::value::Kwargs| {
325                let styles: Option<bool> = kwargs.get("styles")?;
326                kwargs.assert_all_used()?;
327                Ok::<_, minijinja::Error>(Value::from_safe_string(crate::assets::ui_tags(
328                    styles.unwrap_or(true),
329                )))
330            });
331            env.add_function("renox_calendar", || {
332                Value::from_safe_string(crate::assets::calendar_tags())
333            });
334            // The kit's `icon(…)` macro: a Lucide icon as inline SVG.
335            env.add_function(
336                "renox_icon",
337                |name: String, kwargs: minijinja::value::Kwargs| {
338                    let size: Option<u32> = kwargs.get("size")?;
339                    let label: Option<String> = kwargs.get("label")?;
340                    let class: Option<String> = kwargs.get("class")?;
341                    kwargs.assert_all_used()?;
342                    Ok::<_, minijinja::Error>(Value::from_safe_string(crate::icons::svg(
343                        &name,
344                        size.unwrap_or(20),
345                        label.as_deref(),
346                        class.as_deref(),
347                    )))
348                },
349            );
350            env.add_function("renox_grid", || {
351                Value::from_safe_string(crate::assets::grid_tags())
352            });
353            env.add_function("sparkline", crate::view_filters::sparkline);
354            env.add_filter("number", crate::view_filters::number);
355            env.add_filter("date", crate::view_filters::date(zone));
356            env.add_filter("since", crate::view_filters::since(zone));
357            env.add_filter("money", crate::view_filters::money(currency.clone()));
358            env.add_filter("words", crate::view_filters::words);
359            env.add_filter("markdown", crate::view_filters::markdown);
360            env.add_function("chart", crate::chart::chart(currency.clone()));
361            env.add_function("class_names", crate::view_filters::class_names);
362            // The app's own functions and filters (`App::templates`).
363            for hook in hooks.iter() {
364                hook(&mut env);
365            }
366
367            if watch {
368                notifier.watch_path(&dir, true);
369            }
370            Ok(env)
371        });
372        Self {
373            reloader: Arc::new(reloader),
374        }
375    }
376
377    /// Whether a template of this name exists (the app's or a built-in).
378    pub fn exists(&self, name: &str) -> bool {
379        self.reloader
380            .acquire_env()
381            .is_ok_and(|env| env.get_template(name).is_ok())
382    }
383
384    /// Renders a template with the given context and no request globals.
385    pub fn render(&self, name: &str, ctx: impl Serialize) -> anyhow::Result<String> {
386        let env = self.reloader.acquire_env()?;
387        Ok(env.get_template(name)?.render(ctx)?)
388    }
389
390    fn render_view(
391        &self,
392        view: &View,
393        shared: Value,
394        globals: Value,
395        htmx: &Htmx,
396    ) -> anyhow::Result<String> {
397        let env = self.reloader.acquire_env()?;
398        let template = env.get_template(&view.name)?;
399        // Components (macros imported from other templates) don't see this
400        // context; they reach the same values through `RequestGlobal`s.
401        let _current = CurrentGlobals::set(globals.clone());
402        // The last map wins: shared values, then the handler's, then Renox's.
403        let ctx = merge_maps([shared, view.ctx.clone(), globals]);
404        let stacks = crate::view_stack::Scope::begin();
405        let html = match &view.fragment {
406            Some(block) if htmx.wants_fragment() => {
407                let mut captured = template.render_captured_to(ctx, std::io::sink())?;
408                let mut out = captured.with_state_mut(|state| state.render_block(block))?;
409                for extra in &view.also {
410                    out.push_str(&captured.with_state_mut(|state| state.render_block(extra))?);
411                }
412                out
413            }
414            _ => template.render(ctx)?,
415        };
416        Ok(stacks.finish(html))
417    }
418
419    /// The error page: the app's `errors/{status}.html`, else its
420    /// `errors/default.html`, else Renox's. With `globals` (a request's), an
421    /// app's page can extend its layout; a page that fails falls back to
422    /// Renox's, so an error in the layout doesn't hide the first error.
423    fn render_error(
424        &self,
425        page: &ErrorPage,
426        debug: bool,
427        request: &str,
428        globals: Option<Value>,
429    ) -> anyhow::Result<String> {
430        let env = self.reloader.acquire_env()?;
431        let ctx = context! {
432            status => page.status.as_u16(),
433            reason => reason(page.status),
434            detail => page.shown_detail(debug),
435            // Only while developing: what was asked, and where a template failed.
436            debug => debug,
437            request_line => debug.then_some(request),
438            template => page.template.as_deref().filter(|_| debug),
439        };
440        for name in [
441            format!("errors/{}.html", page.status.as_u16()),
442            "errors/default.html".to_owned(),
443        ] {
444            let template = match env.get_template(&name) {
445                Ok(template) => template,
446                Err(err) if err.kind() == ErrorKind::TemplateNotFound => continue,
447                // One that doesn't parse fails like one that doesn't render.
448                Err(err) => {
449                    tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
450                    break;
451                }
452            };
453            let rendered = match &globals {
454                Some(globals) => {
455                    let _current = CurrentGlobals::set(globals.clone());
456                    let stacks = crate::view_stack::Scope::begin();
457                    template
458                        .render(merge_maps([globals.clone(), ctx.clone()]))
459                        .map(|html| stacks.finish(html))
460                }
461                None => template.render(ctx.clone()),
462            };
463            match rendered {
464                Ok(html) => return Ok(html),
465                Err(err) => {
466                    tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
467                    break;
468                }
469            }
470        }
471        Ok(env.get_template("renox/error.html")?.render(ctx)?)
472    }
473}
474
475/// The template a response was rendered from.
476#[derive(Debug, Clone)]
477pub(crate) struct RenderedView(pub String);
478
479/// The globals Renox gives each rendered page (see `globals`), which
480/// components reach through the environment.
481const REQUEST_GLOBALS: &[&str] = &[
482    "app",
483    "auth",
484    "t",
485    "can",
486    "request",
487    "route_is",
488    "csrf_token",
489    "csrf_field",
490    "flash",
491    "errors",
492    "errors_in",
493    "error",
494    "old",
495    "has_old",
496    "renox_head",
497    "seo",
498    "csp_nonce",
499    "toasts",
500    "once",
501];
502
503thread_local! {
504    /// The globals of the page being rendered on this thread.
505    static CURRENT: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
506}
507
508/// Makes `globals` the current page's while alive (rendering is synchronous).
509struct CurrentGlobals(Option<Value>);
510
511impl CurrentGlobals {
512    fn set(globals: Value) -> Self {
513        Self(CURRENT.with(|c| c.borrow_mut().replace(globals)))
514    }
515}
516
517impl Drop for CurrentGlobals {
518    fn drop(&mut self) {
519        let previous = self.0.take();
520        CURRENT.with(|c| *c.borrow_mut() = previous);
521    }
522}
523
524/// A request global as seen from the environment: forwards to the value of
525/// the page being rendered, so an imported macro gets the same `old()`,
526/// `auth`, `t()`… as the template that called it.
527#[derive(Debug)]
528struct RequestGlobal(&'static str);
529
530impl RequestGlobal {
531    fn current(&self) -> Option<Value> {
532        CURRENT
533            .with(|c| c.borrow().clone())
534            .and_then(|globals| globals.get_attr(self.0).ok())
535            .filter(|v| !v.is_undefined())
536    }
537}
538
539impl minijinja::value::Object for RequestGlobal {
540    fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
541        minijinja::value::ObjectRepr::Map
542    }
543
544    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
545        self.current()?
546            .get_item(key)
547            .ok()
548            .filter(|v| !v.is_undefined())
549    }
550
551    fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
552        match self
553            .current()
554            .and_then(|v| v.try_iter().ok().map(|keys| keys.collect::<Vec<_>>()))
555        {
556            Some(keys)
557                if self
558                    .current()
559                    .is_some_and(|v| v.kind() == minijinja::value::ValueKind::Map) =>
560            {
561                minijinja::value::Enumerator::Values(keys)
562            }
563            _ => minijinja::value::Enumerator::Empty,
564        }
565    }
566
567    fn is_true(self: &Arc<Self>) -> bool {
568        self.current().is_some_and(|v| v.is_true())
569    }
570
571    fn call(
572        self: &Arc<Self>,
573        state: &minijinja::State<'_, '_>,
574        args: &[Value],
575    ) -> Result<Value, minijinja::Error> {
576        match self.current() {
577            Some(value) => value.call(state, args),
578            None => Err(minijinja::Error::new(
579                ErrorKind::InvalidOperation,
580                format!("`{}` is only available while rendering a page", self.0),
581            )),
582        }
583    }
584
585    fn render(self: &Arc<Self>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
586        match self.current() {
587            Some(value) => std::fmt::Display::fmt(&value, f),
588            None => Ok(()),
589        }
590    }
591}
592
593/// Like MiniJinja's default formatter, but HTML escaping leaves `/` alone:
594/// escaping `& < > " '` is enough for text and quoted attributes, and URLs
595/// (links in pages and mail) stay readable.
596fn format_value(
597    out: &mut minijinja::Output,
598    state: &minijinja::State,
599    value: &Value,
600) -> Result<(), minijinja::Error> {
601    use std::fmt::Write;
602    if let (minijinja::AutoEscape::Html, false, Some(text)) =
603        (state.auto_escape(), value.is_safe(), value.as_str())
604    {
605        for c in text.chars() {
606            let written = match c {
607                '&' => out.write_str("&amp;"),
608                '<' => out.write_str("&lt;"),
609                '>' => out.write_str("&gt;"),
610                '"' => out.write_str("&quot;"),
611                '\'' => out.write_str("&#39;"),
612                c => out.write_char(c),
613            };
614            written.map_err(|_| {
615                minijinja::Error::new(ErrorKind::WriteFailure, "could not write output")
616            })?;
617        }
618        return Ok(());
619    }
620    minijinja::escape_formatter(out, state, value)
621}
622
623fn load(
624    dir: &Path,
625    embedded: Option<&'static [(&'static str, &'static str)]>,
626    name: &str,
627) -> Result<Option<String>, minijinja::Error> {
628    if let Some(files) = embedded {
629        if let Some((_, source)) = files.iter().find(|(file, _)| *file == name) {
630            return Ok(Some((*source).to_owned()));
631        }
632    } else if let Some(path) = safe_join(dir, name) {
633        match std::fs::read_to_string(&path) {
634            Ok(source) => return Ok(Some(source)),
635            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
636            Err(err) => {
637                return Err(minijinja::Error::new(
638                    ErrorKind::InvalidOperation,
639                    format!("could not read template {}", path.display()),
640                )
641                .with_source(err));
642            }
643        }
644    }
645    Ok(BUILTIN
646        .iter()
647        .find(|(builtin, _)| *builtin == name)
648        .map(|(_, source)| (*source).to_owned()))
649}
650
651fn safe_join(dir: &Path, name: &str) -> Option<PathBuf> {
652    let mut path = dir.to_path_buf();
653    for component in Path::new(name).components() {
654        match component {
655            Component::Normal(part) => path.push(part),
656            _ => return None,
657        }
658    }
659    Some(path)
660}
661
662/// A template response, rendered by Renox with the request's globals:
663/// `app` (with the request's `app.locale`), `request`, `auth` (`auth.check`,
664/// `auth.user`), `t()`, `can()`, `flash`, `errors`, `error()`, `old()`,
665/// `csrf_token`, `csrf_field()` and `renox_head()`.
666///
667/// ```
668/// # use renox::prelude::*;
669/// # let list: Vec<String> = Vec::new();
670/// # let _ = move || {
671/// async fn index() -> View {
672/// #   let list: Vec<String> = Vec::new();
673///     view("products/index.html", context! { products => list }).fragment("list")
674/// }
675/// # };
676/// ```
677#[derive(Clone)]
678pub struct View {
679    name: String,
680    ctx: Value,
681    fragment: Option<String>,
682    /// More blocks sent with the fragment, for out-of-band swaps.
683    also: Vec<String>,
684    status: StatusCode,
685}
686
687/// Renders `name` from the views directory with `ctx` (anything serializable,
688/// usually `context! { ... }`).
689pub fn view(name: impl Into<String>, ctx: impl Serialize) -> View {
690    View {
691        name: name.into(),
692        ctx: Value::from_serialize(ctx),
693        fragment: None,
694        also: Vec::new(),
695        status: StatusCode::OK,
696    }
697}
698
699impl View {
700    /// For HTMX requests (except `hx-boost`), render only this `{% block %}`.
701    pub fn fragment(mut self, block: impl Into<String>) -> Self {
702        self.fragment = Some(block.into());
703        self
704    }
705
706    /// For the same HTMX requests, also render `block` after the fragment,
707    /// to update other parts of the page in one response: give the block's
708    /// root element an `id` and `hx-swap-oob="true"` and htmx swaps it into
709    /// the element with that id.
710    ///
711    /// ```html
712    /// {% block row %}<tr id="order-{{ order.id }}">…</tr>{% endblock %}
713    /// {% block count %}<span id="order-count" hx-swap-oob="true">{{ count }}</span>{% endblock %}
714    /// ```
715    ///
716    /// `view("orders/index.html", ctx).fragment("row").also("count")`
717    pub fn also(mut self, block: impl Into<String>) -> Self {
718        self.also.push(block.into());
719        self
720    }
721
722    /// The response status (200 by default).
723    pub fn status(mut self, status: StatusCode) -> Self {
724        self.status = status;
725        self
726    }
727}
728
729impl IntoResponse for View {
730    fn into_response(self) -> Response {
731        let mut res = self.status.into_response();
732        res.extensions_mut().insert(self);
733        res
734    }
735}
736
737/// Renders `View` and error responses once the handler has returned, and
738/// turns validation errors into a redirect back for regular form posts.
739pub(crate) async fn middleware(
740    State(state): State<AppState>,
741    req: Request,
742    next: Next,
743) -> Response {
744    let session = req.extensions().get::<Session>().cloned();
745    let current_user = req.extensions().get::<CurrentUser>().cloned();
746    let locale = crate::i18n::request_locale(req.extensions(), &state);
747    let htmx = Htmx::from_headers(req.headers());
748    let method = req.method().clone();
749    let path = req.uri().path().to_owned();
750    let query = req.uri().query().unwrap_or_default().to_owned();
751    let route = crate::routing::CurrentRoute::of(req.method(), req.extensions(), &state)
752        .name()
753        .map(str::to_owned);
754    let nonce = req
755        .extensions()
756        .get::<crate::security::CspNonce>()
757        .map(|n| n.0.clone())
758        .unwrap_or_default();
759    let (wants_json, referer) = {
760        let header = |name| req.headers().get(name).and_then(|v| v.to_str().ok());
761        let wants_json = header(ACCEPT).is_some_and(|v| v.contains("application/json"))
762            || header(CONTENT_TYPE).is_some_and(|v| v.starts_with("application/json"));
763        // Back to the form's page, but never to another site.
764        (wants_json, crate::htmx::same_site_referer(req.headers()))
765    };
766
767    let request_line = if query.is_empty() {
768        format!("{method} {path}")
769    } else {
770        format!("{method} {path}?{query}")
771    };
772    let mut res = next.run(req).await;
773
774    if let Some(crate::toast::PendingToasts(toasts)) =
775        res.extensions_mut().remove::<crate::toast::PendingToasts>()
776    {
777        // An htmx swap shows them now; a page, an htmx redirect or refresh
778        // on the next page, from the session (the reload would lose them).
779        if htmx.request
780            && !res.headers().contains_key("hx-redirect")
781            && !res.headers().contains_key("hx-refresh")
782        {
783            crate::htmx::add_trigger(
784                &mut res,
785                crate::toast::EVENT,
786                serde_json::json!({ "toasts": toasts }),
787            );
788        } else if let Some(session) = &session {
789            let mut waiting: Vec<crate::Toast> =
790                session.get(crate::toast::SESSION_KEY).unwrap_or_default();
791            waiting.extend(toasts);
792            if let Err(err) = session.put(crate::toast::SESSION_KEY, &waiting) {
793                return err.into_response();
794            }
795        }
796    }
797
798    if let Some(failed) = res.extensions_mut().remove::<ValidationError>() {
799        if htmx.request || wants_json {
800            return res;
801        }
802        if let Some(session) = &session {
803            let bag = failed.bag().map(str::to_owned);
804            // A `ValidationError` made after `Valid` (a model hook, the
805            // handler) carries no input: refill with what `Valid` read.
806            let input = if failed.input.is_empty() {
807                crate::context::get::<crate::validation::extract::SubmittedInput>()
808                    .map(|submitted| submitted.0)
809                    .unwrap_or_default()
810            } else {
811                failed.input
812            };
813            let flashed = match &bag {
814                Some(bag) => session.flash_errors_in(bag, &failed.errors),
815                None => session.flash_errors(&failed.errors),
816            }
817            .and_then(|()| session.flash_input(&input));
818            if let Err(err) = flashed {
819                return err.into_response();
820            }
821        }
822        return Redirect::to(referer.as_deref().unwrap_or("/")).into_response();
823    }
824
825    // Analytics events: with an htmx swap, in its HX-Trigger; with a page,
826    // in its head (below); otherwise they wait for the next page.
827    if let Some(session) = &session
828        && crate::analytics::has_pending(session)
829        && htmx.request
830        && crate::analytics::deliverable_by_htmx(&res)
831    {
832        crate::analytics::add_trigger(&mut res, crate::analytics::take(session));
833    }
834
835    if let Some(view) = res.extensions_mut().remove::<View>() {
836        let mut shared = std::collections::BTreeMap::new();
837        for (key, compute) in state.shares.iter() {
838            let ctx = ViewContext {
839                state: state.clone(),
840                user: current_user.as_ref().and_then(|c| c.user.clone()),
841                locale: locale.clone(),
842                path: path.clone(),
843            };
844            match compute(ctx).await {
845                Ok(value) => {
846                    shared.insert(key.clone(), value);
847                }
848                Err(err) => {
849                    let err = match err {
850                        Error::Internal(err) => err,
851                        other => anyhow::anyhow!("{other:?}"),
852                    };
853                    // Shown like any error: the error page, with the detail
854                    // while debugging.
855                    let mut failed =
856                        Error::Internal(err.context(format!("sharing `{key}` with views")))
857                            .into_response();
858                    return match failed.extensions_mut().remove::<ErrorPage>() {
859                        Some(page) => error_response(
860                            &state,
861                            page,
862                            failed,
863                            wants_json,
864                            &htmx,
865                            &request_line,
866                            None,
867                        ),
868                        None => failed,
869                    };
870                }
871            }
872        }
873        let events = match &session {
874            Some(session) if !htmx.request => crate::analytics::take(session),
875            _ => Vec::new(),
876        };
877        let globals = globals(
878            &state,
879            session.as_ref(),
880            current_user.clone(),
881            &htmx,
882            &Requested {
883                path: &path,
884                query: &query,
885                route: route.as_deref(),
886                nonce: &nonce,
887                events: &events,
888            },
889            &locale,
890        );
891        return match state
892            .views
893            .render_view(&view, Value::from_serialize(&shared), globals, &htmx)
894        {
895            Ok(html) => {
896                let mut page = with_html(res, html);
897                // For `TestResponse::assert_view`.
898                page.extensions_mut()
899                    .insert(RenderedView(view.name.clone()));
900                page
901            }
902            Err(err) => {
903                let mut failed = Error::Internal(err.context(format!("rendering {}", view.name)))
904                    .into_response();
905                match failed.extensions_mut().remove::<ErrorPage>() {
906                    Some(page) => {
907                        error_response(&state, page, failed, wants_json, &htmx, &request_line, None)
908                    }
909                    None => failed,
910                }
911            }
912        };
913    }
914
915    if let Some(page) = res.extensions_mut().remove::<ErrorPage>() {
916        // The app's error pages may extend its layout: give them what pages
917        // get, the values `App::share`s included (a layout's cart count).
918        let globals = if !wants_json || htmx.request {
919            let mut shared = std::collections::BTreeMap::new();
920            for (key, compute) in state.shares.iter() {
921                let ctx = ViewContext {
922                    state: state.clone(),
923                    user: current_user.as_ref().and_then(|c| c.user.clone()),
924                    locale: locale.clone(),
925                    path: path.clone(),
926                };
927                // A share that fails here only leaves its value out: the page
928                // is about another error, which it mustn't hide.
929                match compute(ctx).await {
930                    Ok(value) => {
931                        shared.insert(key.clone(), value);
932                    }
933                    Err(err) => {
934                        tracing::warn!(error = ?err, key = %key, "a shared view value failed on an error page")
935                    }
936                }
937            }
938            let page_globals = globals(
939                &state,
940                session.as_ref(),
941                current_user,
942                &htmx,
943                &Requested {
944                    path: &path,
945                    query: &query,
946                    route: route.as_deref(),
947                    nonce: &nonce,
948                    events: &[],
949                },
950                &locale,
951            );
952            Some(merge_maps([page_globals, Value::from_serialize(&shared)]))
953        } else {
954            None
955        };
956        return error_response(&state, page, res, wants_json, &htmx, &request_line, globals);
957    }
958    res
959}
960
961/// The error page (or JSON for API clients) for `page`.
962fn error_response(
963    state: &AppState,
964    page: ErrorPage,
965    res: Response,
966    wants_json: bool,
967    htmx: &Htmx,
968    request_line: &str,
969    globals: Option<Value>,
970) -> Response {
971    let debug = state.config.debug;
972    if wants_json && !htmx.request {
973        // Keep what the error response carried (`Retry-After` on a 429, …).
974        let mut json = page.json(debug);
975        for (name, value) in res.headers() {
976            if name != CONTENT_TYPE && name != CONTENT_LENGTH {
977                json.headers_mut().insert(name.clone(), value.clone());
978            }
979        }
980        return json;
981    }
982    let html = state
983        .views
984        .render_error(&page, debug, request_line, globals)
985        .unwrap_or_else(|err| {
986            tracing::error!(error = ?err, "could not render the error page");
987            crate::error::error_page(page.status, page.shown_detail(debug))
988        });
989    with_html(res, html)
990}
991
992fn with_html(mut res: Response, html: String) -> Response {
993    let headers = res.headers_mut();
994    headers.insert(
995        CONTENT_TYPE,
996        HeaderValue::from_static("text/html; charset=utf-8"),
997    );
998    headers.remove(CONTENT_LENGTH);
999    *res.body_mut() = Body::from(html);
1000    res
1001}
1002
1003/// Flashed values; a key that wasn't flashed reads as `""`, so
1004/// `{{ flash.status }}` needs no `if`, even with strict templates.
1005#[derive(Debug)]
1006struct Flashed(serde_json::Map<String, serde_json::Value>);
1007
1008impl minijinja::value::Object for Flashed {
1009    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1010        let key = key.as_str()?;
1011        Some(
1012            self.0
1013                .get(key)
1014                .map(Value::from_serialize)
1015                .unwrap_or_else(|| Value::from("")),
1016        )
1017    }
1018
1019    fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
1020        minijinja::value::Enumerator::Values(
1021            self.0.keys().map(|k| Value::from(k.as_str())).collect(),
1022        )
1023    }
1024
1025    fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
1026        minijinja::value::ObjectRepr::Map
1027    }
1028}
1029
1030/// What templates see of the request, besides the session and user.
1031struct Requested<'a> {
1032    path: &'a str,
1033    query: &'a str,
1034    /// The matched route's name, if it has one.
1035    route: Option<&'a str>,
1036    nonce: &'a str,
1037    /// Analytics events for this page's head.
1038    events: &'a [crate::analytics::Event],
1039}
1040
1041/// The templates' `t(key, name=…, count=…)` in `locale`.
1042pub(crate) fn translate_function(state: &AppState, locale: &str) -> Value {
1043    let translator = state.translator.clone();
1044    let (locale, fallback) = (locale.to_owned(), state.config.fallback_locale.clone());
1045    Value::from_function(
1046        move |key: String, kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
1047            let mut params = Vec::new();
1048            let mut count = None;
1049            for name in kwargs.args() {
1050                let value: Value = kwargs.get(name)?;
1051                if name == "count" {
1052                    match i64::try_from(value.clone()) {
1053                        Ok(whole) => count = Some(whole),
1054                        // Not a whole number (a fraction, or text such as
1055                        // "1,204"): `:count` shows it as given, and a plural
1056                        // form is still picked by its value when it has one.
1057                        Err(_) => {
1058                            let shown = value.to_string();
1059                            let number = f64::try_from(value)
1060                                .ok()
1061                                .or_else(|| shown.trim().parse::<f64>().ok());
1062                            count = number.map(plural_count);
1063                            params.push((name.to_owned(), shown));
1064                        }
1065                    }
1066                } else {
1067                    params.push((name.to_owned(), value.to_string()));
1068                }
1069            }
1070            kwargs.assert_all_used()?;
1071            let params: Vec<(&str, String)> = params
1072                .iter()
1073                .map(|(k, v)| (k.as_str(), v.clone()))
1074                .collect();
1075            let text = translator.get(&locale, &fallback, &key);
1076            Ok(Value::from(crate::i18n::format(&text, &params, count)))
1077        },
1078    )
1079}
1080
1081/// The count a number stands for when picking a plural form: one only for
1082/// exactly 1 (so 1.5 or 0.5 kilos are plural), else the nearest whole one
1083/// (for ranges such as `[2,5]`).
1084fn plural_count(number: f64) -> i64 {
1085    if number == 1.0 {
1086        1
1087    } else {
1088        match number.round() as i64 {
1089            1 => 2,
1090            other => other,
1091        }
1092    }
1093}
1094
1095fn globals(
1096    state: &AppState,
1097    session: Option<&Session>,
1098    current_user: Option<CurrentUser>,
1099    htmx: &Htmx,
1100    requested: &Requested,
1101    locale: &str,
1102) -> Value {
1103    let config = &state.config;
1104    let token = session.map(Session::token).unwrap_or_default();
1105
1106    let strict = state.security.mode == crate::CspMode::Strict;
1107    let head = Value::from_safe_string(format!(
1108        "{}\n{}",
1109        assets::head_tags(&token, state.live.is_some(), strict),
1110        crate::seo::head_tags(&state.config, requested.nonce, requested.events)
1111    ));
1112    let seo = {
1113        let (config, path, locale) = (
1114            state.config.clone(),
1115            requested.path.to_owned(),
1116            locale.to_owned(),
1117        );
1118        Value::from_function(
1119            move |kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
1120                crate::seo::tags(&config, &path, &locale, &kwargs).map(Value::from_safe_string)
1121            },
1122        )
1123    };
1124    let nonce = requested.nonce.to_owned();
1125    let field = Value::from_safe_string(format!(
1126        "<input type=\"hidden\" name=\"{}\" value=\"{token}\">",
1127        crate::csrf::CSRF_FIELD
1128    ));
1129    let old_input = session.cloned();
1130    let has_old = session.is_some_and(Session::has_old_input);
1131    let toast_session = session.cloned();
1132    let toast_texts = crate::toast::RegionTexts {
1133        dismiss: state
1134            .translator
1135            .get(locale, &state.config.fallback_locale, "ui.dismiss"),
1136        failed: state
1137            .translator
1138            .get(locale, &state.config.fallback_locale, "ui.request_failed"),
1139    };
1140    let seen = std::sync::Mutex::new(std::collections::HashSet::<String>::new());
1141    let user = current_user.as_ref().and_then(|c| c.user.clone());
1142    let roles = current_user
1143        .as_ref()
1144        .map(|c| c.grants.roles())
1145        .unwrap_or_default();
1146    let gate_user = current_user.and_then(|c| Some((c.user?, c.gates, c.grants)));
1147    // Errors flashed in a named bag (`Validate::ERROR_BAG`) are shown only
1148    // by `error(field, bag=…)` and `errors_in(bag)`.
1149    let error_bag = session.and_then(Session::error_bag);
1150    let flashed_errors = match (session, &error_bag) {
1151        (Some(session), Some(bag)) => session.errors_in(bag),
1152        (Some(session), None) => session.errors(),
1153        (None, _) => Default::default(),
1154    };
1155    let errors = if error_bag.is_none() {
1156        flashed_errors.clone()
1157    } else {
1158        Default::default()
1159    };
1160    let first_errors: std::collections::BTreeMap<String, String> = flashed_errors
1161        .iter()
1162        .filter_map(|(field, messages)| {
1163            messages
1164                .get(0)
1165                .and_then(|m| m.as_str())
1166                .map(|m| (field.clone(), m.to_owned()))
1167        })
1168        .collect();
1169
1170    context! {
1171        app => context! {
1172            name => config.name,
1173            env => format!("{:?}", config.env).to_lowercase(),
1174            debug => config.debug,
1175            url => config.url,
1176            locale => locale,
1177            currency => config.currency,
1178        },
1179        auth => context! {
1180            check => user.is_some(),
1181            user => user.as_deref(),
1182            roles => roles,
1183        },
1184        t => translate_function(state, locale),
1185        // `can('admin')` asks a gate; `can('update', product)` reads the
1186        // abilities `auth::Can` attached to the model in the handler.
1187        can => Value::from_function(move |ability: String, target: Option<Value>| {
1188            match target {
1189                Some(target) => target
1190                    .get_attr("_can")
1191                    .and_then(|can| can.get_attr(&ability))
1192                    .is_ok_and(|allowed| allowed.is_true()),
1193                None => gate_user
1194                    .as_ref()
1195                    .is_some_and(|(user, gates, grants)| gates.check(user, grants, &ability)),
1196            }
1197        }),
1198        request => context! {
1199            path => requested.path,
1200            query => requested.query,
1201            route => requested.route,
1202            htmx => htmx.request,
1203            boosted => htmx.boosted,
1204        },
1205        // `route_is('admin.*')`, `route_is('products.index', 'products.show')`.
1206        route_is => {
1207            let route = requested.route.map(str::to_owned);
1208            Value::from_function(move |patterns: minijinja::value::Rest<String>| {
1209                route.as_deref().is_some_and(|name| {
1210                    patterns
1211                        .iter()
1212                        .any(|pattern| crate::routing::route_name_matches(name, pattern))
1213                })
1214            })
1215        },
1216        csrf_token => token,
1217        flash => Value::from_object(Flashed(session.map(Session::flashed).unwrap_or_default())),
1218        errors => errors,
1219        // `errors_in('login')`: every error of a named bag.
1220        errors_in => {
1221            let bag = error_bag.clone();
1222            Value::from_function(move |name: String| {
1223                if bag.as_deref() == Some(name.as_str()) {
1224                    Value::from_serialize(&flashed_errors)
1225                } else {
1226                    Value::from_serialize(serde_json::Map::new())
1227                }
1228            })
1229        },
1230        // `error('photos')` also shows the first error of an item (`photos.1`);
1231        // `error('email', bag='login')` reads a named bag.
1232        error => Value::from_function(move |field: String, kwargs: minijinja::value::Kwargs| {
1233            let bag: Option<String> = kwargs.get("bag")?;
1234            kwargs.assert_all_used()?;
1235            if bag != error_bag {
1236                return Ok::<_, minijinja::Error>(String::new());
1237            }
1238            // `items[0][name]`'s errors are keyed `items.0.name`.
1239            let field = crate::validation::nested::normalize(&field);
1240            Ok(first_errors
1241                .get(&field)
1242                .or_else(|| {
1243                    let prefix = format!("{field}.");
1244                    first_errors
1245                        .iter()
1246                        .find(|(key, _)| key.starts_with(&prefix))
1247                        .map(|(_, message)| message)
1248                })
1249                .cloned()
1250                .unwrap_or_default())
1251        }),
1252        renox_head => Value::from_function(move || head.clone()),
1253        seo => seo,
1254        csp_nonce => Value::from_function(move || nonce.clone()),
1255        csrf_field => Value::from_function(move || field.clone()),
1256        // `{{ toasts() }}`: the toast region, with the toasts waiting for
1257        // this page (taken from the session: shown once).
1258        // `toasts(position="bottom-end")` moves them (see toast::POSITIONS).
1259        toasts => Value::from_function(move |kwargs: minijinja::value::Kwargs| {
1260            let position: Option<String> = kwargs.get("position")?;
1261            kwargs.assert_all_used()?;
1262            let waiting: Vec<crate::Toast> = toast_session
1263                .as_ref()
1264                .and_then(|s| s.pull(crate::toast::SESSION_KEY))
1265                .unwrap_or_default();
1266            Ok::<_, minijinja::Error>(Value::from_safe_string(crate::toast::region(
1267                &waiting,
1268                &toast_texts,
1269                position.as_deref().unwrap_or("top"),
1270            )))
1271        }),
1272        // `{% if once('date-picker') %}…{% endif %}`: true the first time a key
1273        // is asked for on a page, e.g. for a component's script.
1274        once => Value::from_function(move |key: String| {
1275            seen.lock().unwrap_or_else(|e| e.into_inner()).insert(key)
1276        }),
1277        // `has_old()`: the previous request was a failed submit, so a field
1278        // missing from `old()` was sent empty (an unticked checkbox).
1279        has_old => Value::from_function(move || has_old),
1280        old => Value::from_function(move |field: String, default: Option<Value>| {
1281            old_input
1282                .as_ref()
1283                .and_then(|s| s.old(&field))
1284                .map(Value::from_serialize)
1285                .or(default)
1286                .unwrap_or_else(|| Value::from(""))
1287        }),
1288    }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293    use super::*;
1294
1295    fn env() -> Environment<'static> {
1296        let mut env = Environment::new();
1297        for name in REQUEST_GLOBALS {
1298            env.add_global(*name, Value::from_object(RequestGlobal(name)));
1299        }
1300        env
1301    }
1302
1303    /// A template rendered while a page renders (a component a Rust
1304    /// template function renders, say) reads the page's request values
1305    /// through the `RequestGlobal`s; outside a page they are empty.
1306    #[test]
1307    fn request_globals_read_the_page_being_rendered() {
1308        let env = env();
1309        let page = "{% if auth %}[{{ auth.name }}]{% endif %}\
1310                    {% for key in flash %}{{ key }}={{ flash[key] }};{% endfor %}\
1311                    {{ t('hi') }}|{{ request }}|{% for e in errors %}{{ e }}{% endfor %}.";
1312
1313        let outside = env
1314            .render_str(
1315                "{% if auth %}in{% endif %}[{{ request }}]{% for k in flash %}{{ k }}{% endfor %}",
1316                (),
1317            )
1318            .unwrap();
1319        assert_eq!(outside, "[]");
1320        let err = env.render_str("{{ t('hi') }}", ()).unwrap_err();
1321        assert!(
1322            err.to_string()
1323                .contains("`t` is only available while rendering a page"),
1324            "{err}"
1325        );
1326
1327        let globals = minijinja::context! {
1328            auth => minijinja::context! { name => "Ann" },
1329            flash => minijinja::context! { status => "Saved" },
1330            t => Value::from_function(|key: String| format!("({key})")),
1331            request => "GET /",
1332            // A list: not a map, so it lists no keys.
1333            errors => vec!["a"],
1334        };
1335        let current = CurrentGlobals::set(globals);
1336        assert_eq!(
1337            env.render_str(page, ()).unwrap(),
1338            "[Ann]status=Saved;(hi)|GET /|."
1339        );
1340        drop(current);
1341        assert_eq!(env.render_str("{{ request }}", ()).unwrap(), "");
1342    }
1343}