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