Skip to main content

mas_templates/
lib.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8#![deny(missing_docs)]
9#![allow(clippy::module_name_repetitions)]
10
11//! Templates rendering
12
13use std::{
14    collections::{BTreeMap, HashSet},
15    sync::Arc,
16};
17
18use anyhow::Context as _;
19use arc_swap::ArcSwap;
20use camino::{Utf8Path, Utf8PathBuf};
21use mas_i18n::Translator;
22use mas_router::UrlBuilder;
23use mas_spa::ViteManifest;
24use minijinja::{UndefinedBehavior, Value};
25use rand::RngExt;
26use serde::Serialize;
27use thiserror::Error;
28use tokio::task::JoinError;
29use tracing::{debug, info};
30use walkdir::DirEntry;
31
32mod context;
33mod forms;
34mod functions;
35
36#[macro_use]
37mod macros;
38
39pub use self::{
40    context::{
41        AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext,
42        CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
43        DeviceLinkFormField, DeviceNameContext, EmailInviteContext, EmailRecoveryContext,
44        EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
45        InviteContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext,
46        PostAuthContext, PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext,
47        RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
48        RecoveryStartFormField, RegisterContext, RegisterFormField,
49        RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
50        RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
51        RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
52        RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
53        TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
54        UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
55    },
56    forms::{FieldError, FormError, FormField, FormState, ToFormState},
57};
58use crate::context::SampleIdentifier;
59
60/// Escape the given string for use in HTML
61///
62/// It uses the same crate as the one used by the minijinja templates
63#[must_use]
64pub fn escape_html(input: &str) -> String {
65    let mut out = String::with_capacity(input.len());
66    v_htmlescape::escape_string(input, &mut out);
67    out
68}
69
70/// Wrapper around [`minijinja::Environment`] helping rendering the various
71/// templates
72#[derive(Debug, Clone)]
73pub struct Templates {
74    environment: Arc<ArcSwap<minijinja::Environment<'static>>>,
75    translator: Arc<ArcSwap<Translator>>,
76    url_builder: UrlBuilder,
77    branding: SiteBranding,
78    features: SiteFeatures,
79    vite_manifest_path: Option<Utf8PathBuf>,
80    translations_path: Utf8PathBuf,
81    path: Utf8PathBuf,
82    /// Whether template rendering is in strict mode (for testing,
83    /// until this can be rolled out in production.)
84    strict: bool,
85}
86
87/// There was an issue while loading the templates
88#[derive(Error, Debug)]
89pub enum TemplateLoadingError {
90    /// I/O error
91    #[error(transparent)]
92    IO(#[from] std::io::Error),
93
94    /// Failed to read the assets manifest
95    #[error("failed to read the assets manifest")]
96    ViteManifestIO(#[source] std::io::Error),
97
98    /// Failed to deserialize the assets manifest
99    #[error("invalid assets manifest")]
100    ViteManifest(#[from] serde_json::Error),
101
102    /// Failed to load the translations
103    #[error("failed to load the translations")]
104    Translations(#[from] mas_i18n::LoadError),
105
106    /// Failed to traverse the filesystem
107    #[error("failed to traverse the filesystem")]
108    WalkDir(#[from] walkdir::Error),
109
110    /// Encountered non-UTF-8 path
111    #[error("encountered non-UTF-8 path")]
112    NonUtf8Path(#[from] camino::FromPathError),
113
114    /// Encountered non-UTF-8 path
115    #[error("encountered non-UTF-8 path")]
116    NonUtf8PathBuf(#[from] camino::FromPathBufError),
117
118    /// Encountered invalid path
119    #[error("encountered invalid path")]
120    InvalidPath(#[from] std::path::StripPrefixError),
121
122    /// Some templates failed to compile
123    #[error("could not load and compile some templates")]
124    Compile(#[from] minijinja::Error),
125
126    /// Could not join blocking task
127    #[error("error from async runtime")]
128    Runtime(#[from] JoinError),
129
130    /// There are essential templates missing
131    #[error("missing templates {missing:?}")]
132    MissingTemplates {
133        /// List of missing templates
134        missing: HashSet<String>,
135        /// List of templates that were loaded
136        loaded: HashSet<String>,
137    },
138}
139
140fn is_hidden(entry: &DirEntry) -> bool {
141    entry
142        .file_name()
143        .to_str()
144        .is_some_and(|s| s.starts_with('.'))
145}
146
147impl Templates {
148    /// Load the templates from the given config
149    ///
150    /// # Parameters
151    ///
152    /// - `vite_manifest_path`: None if we are rendering resources for
153    ///   reproducibility, in which case a dummy Vite manifest will be used.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the templates could not be loaded from disk.
158    #[tracing::instrument(
159        name = "templates.load",
160        skip_all,
161        fields(%path),
162    )]
163    pub async fn load(
164        path: Utf8PathBuf,
165        url_builder: UrlBuilder,
166        vite_manifest_path: Option<Utf8PathBuf>,
167        translations_path: Utf8PathBuf,
168        branding: SiteBranding,
169        features: SiteFeatures,
170        strict: bool,
171    ) -> Result<Self, TemplateLoadingError> {
172        let (translator, environment) = Self::load_(
173            &path,
174            url_builder.clone(),
175            vite_manifest_path.as_deref(),
176            &translations_path,
177            branding.clone(),
178            features,
179            strict,
180        )
181        .await?;
182        Ok(Self {
183            environment: Arc::new(ArcSwap::new(environment)),
184            translator: Arc::new(ArcSwap::new(translator)),
185            path,
186            url_builder,
187            vite_manifest_path,
188            translations_path,
189            branding,
190            features,
191            strict,
192        })
193    }
194
195    async fn load_(
196        path: &Utf8Path,
197        url_builder: UrlBuilder,
198        vite_manifest_path: Option<&Utf8Path>,
199        translations_path: &Utf8Path,
200        branding: SiteBranding,
201        features: SiteFeatures,
202        strict: bool,
203    ) -> Result<(Arc<Translator>, Arc<minijinja::Environment<'static>>), TemplateLoadingError> {
204        let path = path.to_owned();
205        let span = tracing::Span::current();
206
207        // Read the assets manifest from disk
208        let vite_manifest = if let Some(vite_manifest_path) = vite_manifest_path {
209            let raw_vite_manifest = tokio::fs::read(vite_manifest_path)
210                .await
211                .map_err(TemplateLoadingError::ViteManifestIO)?;
212
213            Some(
214                serde_json::from_slice::<ViteManifest>(&raw_vite_manifest)
215                    .map_err(TemplateLoadingError::ViteManifest)?,
216            )
217        } else {
218            None
219        };
220
221        // Parse it
222
223        let translations_path = translations_path.to_owned();
224        let translator =
225            tokio::task::spawn_blocking(move || Translator::load_from_path(&translations_path))
226                .await??;
227        let translator = Arc::new(translator);
228
229        debug!(locales = ?translator.available_locales(), "Loaded translations");
230
231        let (loaded, mut env) = tokio::task::spawn_blocking(move || {
232            span.in_scope(move || {
233                let mut loaded: HashSet<_> = HashSet::new();
234                let mut env = minijinja::Environment::new();
235                // Don't allow use of undefined variables
236                env.set_undefined_behavior(if strict {
237                    UndefinedBehavior::Strict
238                } else {
239                    // For now, allow semi-strict, because we don't have total test coverage of
240                    // tests and some tests rely on if conditions against sometimes-undefined
241                    // variables
242                    UndefinedBehavior::SemiStrict
243                });
244                let root = path.canonicalize_utf8()?;
245                info!(%root, "Loading templates from filesystem");
246                for entry in walkdir::WalkDir::new(&root)
247                    .min_depth(1)
248                    .into_iter()
249                    .filter_entry(|e| !is_hidden(e))
250                {
251                    let entry = entry?;
252                    if entry.file_type().is_file() {
253                        let path = Utf8PathBuf::try_from(entry.into_path())?;
254                        let Some(ext) = path.extension() else {
255                            continue;
256                        };
257
258                        if ext == "html" || ext == "txt" || ext == "subject" {
259                            let relative = path.strip_prefix(&root)?;
260                            debug!(%relative, "Registering template");
261                            let template = std::fs::read_to_string(&path)?;
262                            env.add_template_owned(relative.as_str().to_owned(), template)?;
263                            loaded.insert(relative.as_str().to_owned());
264                        }
265                    }
266                }
267
268                Ok::<_, TemplateLoadingError>((loaded, env))
269            })
270        })
271        .await??;
272
273        env.add_global("branding", Value::from_object(branding));
274        env.add_global("features", Value::from_object(features));
275
276        self::functions::register(
277            &mut env,
278            url_builder,
279            vite_manifest,
280            Arc::clone(&translator),
281        );
282
283        let env = Arc::new(env);
284
285        let needed: HashSet<_> = TEMPLATES.into_iter().map(ToOwned::to_owned).collect();
286        debug!(?loaded, ?needed, "Templates loaded");
287        let missing: HashSet<_> = needed.difference(&loaded).cloned().collect();
288
289        if missing.is_empty() {
290            Ok((translator, env))
291        } else {
292            Err(TemplateLoadingError::MissingTemplates { missing, loaded })
293        }
294    }
295
296    /// Reload the templates on disk
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the templates could not be reloaded from disk.
301    #[tracing::instrument(
302        name = "templates.reload",
303        skip_all,
304        fields(path = %self.path),
305    )]
306    pub async fn reload(&self) -> Result<(), TemplateLoadingError> {
307        let (translator, environment) = Self::load_(
308            &self.path,
309            self.url_builder.clone(),
310            self.vite_manifest_path.as_deref(),
311            &self.translations_path,
312            self.branding.clone(),
313            self.features,
314            self.strict,
315        )
316        .await?;
317
318        // Swap them
319        self.environment.store(environment);
320        self.translator.store(translator);
321
322        Ok(())
323    }
324
325    /// Get the translator
326    #[must_use]
327    pub fn translator(&self) -> Arc<Translator> {
328        self.translator.load_full()
329    }
330}
331
332/// Failed to render a template
333#[derive(Error, Debug)]
334pub enum TemplateError {
335    /// Missing template
336    #[error("missing template {template:?}")]
337    Missing {
338        /// The name of the template being rendered
339        template: &'static str,
340
341        /// The underlying error
342        #[source]
343        source: minijinja::Error,
344    },
345
346    /// Failed to render the template
347    #[error("could not render template {template:?}")]
348    Render {
349        /// The name of the template being rendered
350        template: &'static str,
351
352        /// The underlying error
353        #[source]
354        source: minijinja::Error,
355    },
356}
357
358register_templates! {
359    /// Render the not found fallback page
360    pub fn render_not_found(WithLanguage<NotFoundContext>) { "pages/404.html" }
361
362    /// Render the frontend app
363    pub fn render_app(WithLanguage<AppContext>) { "app.html" }
364
365    /// Render the Swagger API reference
366    pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
367
368    /// Render the Swagger OAuth callback page
369    pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
370
371    /// Render the login page
372    pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
373
374    /// Render the registration page
375    pub fn render_register(WithLanguage<WithCsrf<WithCaptcha<RegisterContext>>>) { "pages/register/index.html" }
376
377    /// Render the email verification page
378    pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
379
380    /// Render the email in use page
381    pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
382
383    /// Render the display name page
384    pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
385
386    /// Render the registration token page
387    pub fn render_register_steps_registration_token(WithLanguage<WithCsrf<RegisterStepsRegistrationTokenContext>>) { "pages/register/steps/registration_token.html" }
388
389    /// Render the client consent page
390    pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
391
392    /// Render the policy violation page
393    pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
394
395    /// Render the compatibility login policy violation page
396    pub fn render_compat_login_policy_violation(WithLanguage<WithCsrf<WithSession<CompatLoginPolicyViolationContext>>>) { "pages/compat_login_policy_violation.html" }
397
398    /// Render the legacy SSO login consent page
399    pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
400
401    /// Render the home page
402    pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
403
404    /// Render the account recovery start page
405    pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
406
407    /// Render the account recovery start page
408    pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
409
410    /// Render the account recovery finish page
411    pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
412
413    /// Render the account recovery link expired page
414    pub fn render_recovery_expired(WithLanguage<WithCsrf<RecoveryExpiredContext>>) { "pages/recovery/expired.html" }
415
416    /// Render the account recovery link consumed page
417    pub fn render_recovery_consumed(WithLanguage<EmptyContext>) { "pages/recovery/consumed.html" }
418
419    /// Render the account recovery disabled page
420    pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
421
422    /// Render the form used by the `form_post` response mode
423    pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
424
425    /// Render the HTML error page
426    pub fn render_error(ErrorContext) { "pages/error.html" }
427
428    /// Render the invite email (plain text variant)
429    pub fn render_email_invite_txt(WithLanguage<EmailInviteContext>) { "emails/invite.txt" }
430
431    /// Render the invite email (HTML variant)
432    pub fn render_email_invite_html(WithLanguage<EmailInviteContext>) { "emails/invite.html" }
433
434    /// Render the invite email subject
435    pub fn render_email_invite_subject(WithLanguage<EmailInviteContext>) { "emails/invite.subject" }
436
437    /// Render the email recovery email (plain text variant)
438    pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
439
440    /// Render the email recovery email (HTML text variant)
441    pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
442
443    /// Render the email recovery subject
444    pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
445
446    /// Render the email verification email (plain text variant)
447    pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }
448
449    /// Render the email verification email (HTML text variant)
450    pub fn render_email_verification_html(WithLanguage<EmailVerificationContext>) { "emails/verification.html" }
451
452    /// Render the email verification subject
453    pub fn render_email_verification_subject(WithLanguage<EmailVerificationContext>) { "emails/verification.subject" }
454
455    /// Render the upstream link mismatch message
456    pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
457
458    /// Render the upstream suggest link message
459    pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
460
461    /// Render the upstream register screen
462    pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
463
464    /// Render the device code link page
465    pub fn render_device_link(WithLanguage<WithCsrf<DeviceLinkContext>>) { "pages/device_link.html" }
466
467    /// Render the device code consent page
468    pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
469
470    /// Render the 'account deactivated' page
471    pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
472
473    /// Render the 'account locked' page
474    pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
475
476    /// Render the 'account logged out' page
477    pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
478
479    /// Render the automatic device name for OAuth 2.0 client
480    pub fn render_device_name(WithLanguage<DeviceNameContext>) { "device_name.txt" }
481}
482
483impl Templates {
484    /// Render all templates with the generated samples to check if they render
485    /// properly.
486    ///
487    /// Returns the renders in a map whose keys are template names
488    /// and the values are lists of renders (according to the list
489    /// of samples).
490    /// Samples are stable across re-runs and can be used for
491    /// acceptance testing.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if any of the templates fails to render
496    pub fn check_render<R: RngExt + Clone>(
497        &self,
498        now: chrono::DateTime<chrono::Utc>,
499        rng: &R,
500    ) -> anyhow::Result<BTreeMap<(&'static str, SampleIdentifier), String>> {
501        check::all(self, now, rng)
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use rand::SeedableRng;
508
509    use super::*;
510
511    fn test_url_builder() -> UrlBuilder {
512        UrlBuilder::new("https://example.com/".parse().unwrap(), None, None)
513    }
514
515    /// Load the built-in templates in strict mode, against either the real
516    /// vite manifest or the 'dummy' one used for reproducible renders
517    async fn load_templates(use_real_vite_manifest: bool) -> Templates {
518        let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/");
519        let features = SiteFeatures {
520            password_login: true,
521            password_registration: true,
522            password_registration_email_required: true,
523            account_recovery: true,
524            login_with_email_allowed: true,
525            minimum_password_complexity: 3,
526        };
527        let vite_manifest_path =
528            Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
529        let translations_path =
530            Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations");
531
532        Templates::load(
533            path,
534            test_url_builder(),
535            use_real_vite_manifest.then_some(vite_manifest_path),
536            translations_path,
537            SiteBranding::new("example.com"),
538            features,
539            true,
540        )
541        .await
542        .unwrap()
543    }
544
545    #[tokio::test]
546    async fn check_builtin_templates() {
547        #[expect(clippy::disallowed_methods)]
548        let now = chrono::Utc::now();
549        let rng = rand_chacha::ChaCha8Rng::from_seed([42; 32]);
550
551        for use_real_vite_manifest in [true, false] {
552            let templates = load_templates(use_real_vite_manifest).await;
553
554            // Check the renders are deterministic, when given the same rng
555            let render1 = templates.check_render(now, &rng).unwrap();
556            let render2 = templates.check_render(now, &rng).unwrap();
557
558            assert_eq!(render1, render2);
559        }
560    }
561
562    /// The headline escapes the room and the inviter once in HTML, the subject
563    /// names neither, and the link is the token's invite link
564    #[tokio::test]
565    async fn invite_email() {
566        let templates = load_templates(false).await;
567
568        let context = EmailInviteContext::new(
569            true,
570            Some("Q&A <team>".to_owned()),
571            Some("@alice:example.com".to_owned()),
572            Some("Alice O'Brien & Co".to_owned()),
573            test_url_builder()
574                .absolute_url_for(&mas_router::Register::default().with_token("token".to_owned())),
575        )
576        .with_language(mas_i18n::locale!("en").into());
577
578        let html = templates.render_email_invite_html(&context).unwrap();
579        assert!(
580            html.contains(
581                "Alice O&#x27;Brien &amp; Co (@alice:example.com) invited you to ask to join \
582                 Q&amp;A &lt;team&gt; on example.com."
583            ),
584            "{html}"
585        );
586
587        let txt = templates.render_email_invite_txt(&context).unwrap();
588        assert!(
589            txt.contains(
590                "Alice O'Brien & Co (@alice:example.com) invited you to ask to join Q&A <team> \
591                 on example.com."
592            ),
593            "{txt}"
594        );
595        assert!(
596            txt.contains("https://example.com/register?token=token\n"),
597            "{txt}"
598        );
599
600        let subject = templates.render_email_invite_subject(&context).unwrap();
601        assert_eq!(
602            subject.trim(),
603            "You have been invited to a room on example.com"
604        );
605    }
606
607    /// An invite which opens no room names none and asks for no approval
608    #[tokio::test]
609    async fn invite_email_no_room() {
610        let templates = load_templates(false).await;
611
612        let context = EmailInviteContext::new(
613            false,
614            None,
615            Some("@alice:example.com".to_owned()),
616            None,
617            test_url_builder()
618                .absolute_url_for(&mas_router::Register::default().with_token("token".to_owned())),
619        )
620        .with_language(mas_i18n::locale!("en").into());
621
622        let txt = templates.render_email_invite_txt(&context).unwrap();
623        assert!(
624            txt.starts_with("@alice:example.com invited you to join example.com.\n"),
625            "{txt}"
626        );
627
628        let html = templates.render_email_invite_html(&context).unwrap();
629        assert!(!html.contains("approve"), "{html}");
630
631        let subject = templates.render_email_invite_subject(&context).unwrap();
632        assert_eq!(subject.trim(), "You have been invited to example.com");
633    }
634}