mas_templates/
lib.rs

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