mas_templates/
lib.rs

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