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
21const 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#[derive(Clone)]
118pub struct Views {
119 reloader: Arc<AutoReloader>,
120}
121
122#[non_exhaustive]
124#[derive(Clone)]
125pub struct ViewContext {
126 pub state: AppState,
128 pub user: Option<Arc<crate::auth::User>>,
130 pub locale: String,
132 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
158pub(crate) type TemplateHook = Arc<dyn Fn(&mut Environment<'static>) + Send + Sync>;
161
162impl Views {
163 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 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 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 let mut url = routes.url(&name, ¶ms).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('&', "&"));
218 }
219 }
220 Ok(Value::from_safe_string(url))
221 },
222 );
223 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 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 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 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 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 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 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 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 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 let _current = CurrentGlobals::set(globals.clone());
402 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 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 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 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#[derive(Debug, Clone)]
477pub(crate) struct RenderedView(pub String);
478
479const 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 static CURRENT: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
506}
507
508struct 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#[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
593fn 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("&"),
608 '<' => out.write_str("<"),
609 '>' => out.write_str(">"),
610 '"' => out.write_str("""),
611 '\'' => out.write_str("'"),
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#[derive(Clone)]
678pub struct View {
679 name: String,
680 ctx: Value,
681 fragment: Option<String>,
682 also: Vec<String>,
684 status: StatusCode,
685}
686
687pub 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 pub fn fragment(mut self, block: impl Into<String>) -> Self {
702 self.fragment = Some(block.into());
703 self
704 }
705
706 pub fn also(mut self, block: impl Into<String>) -> Self {
718 self.also.push(block.into());
719 self
720 }
721
722 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
737pub(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 (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 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 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 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 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 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 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 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
961fn 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 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#[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
1030struct Requested<'a> {
1032 path: &'a str,
1033 query: &'a str,
1034 route: Option<&'a str>,
1036 nonce: &'a str,
1037 events: &'a [crate::analytics::Event],
1039}
1040
1041pub(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 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, ¶ms, count)))
1077 },
1078 )
1079}
1080
1081fn 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 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 => 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 => {
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 => {
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 => 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 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 => 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 once => Value::from_function(move |key: String| {
1275 seen.lock().unwrap_or_else(|e| e.into_inner()).insert(key)
1276 }),
1277 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 #[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 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}