1#![deny(missing_docs)]
8#![allow(clippy::module_name_repetitions)]
9
10use 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, RegisterStepsVerifyEmailContext,
46        RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
47        TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
48        UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
49    },
50    forms::{FieldError, FormError, FormField, FormState, ToFormState},
51};
52
53#[must_use]
57pub fn escape_html(input: &str) -> String {
58    v_htmlescape::escape(input).to_string()
59}
60
61#[derive(Debug, Clone)]
64pub struct Templates {
65    environment: Arc<ArcSwap<minijinja::Environment<'static>>>,
66    translator: Arc<ArcSwap<Translator>>,
67    url_builder: UrlBuilder,
68    branding: SiteBranding,
69    features: SiteFeatures,
70    vite_manifest_path: Utf8PathBuf,
71    translations_path: Utf8PathBuf,
72    path: Utf8PathBuf,
73}
74
75#[derive(Error, Debug)]
77pub enum TemplateLoadingError {
78    #[error(transparent)]
80    IO(#[from] std::io::Error),
81
82    #[error("failed to read the assets manifest")]
84    ViteManifestIO(#[source] std::io::Error),
85
86    #[error("invalid assets manifest")]
88    ViteManifest(#[from] serde_json::Error),
89
90    #[error("failed to load the translations")]
92    Translations(#[from] mas_i18n::LoadError),
93
94    #[error("failed to traverse the filesystem")]
96    WalkDir(#[from] walkdir::Error),
97
98    #[error("encountered non-UTF-8 path")]
100    NonUtf8Path(#[from] camino::FromPathError),
101
102    #[error("encountered non-UTF-8 path")]
104    NonUtf8PathBuf(#[from] camino::FromPathBufError),
105
106    #[error("encountered invalid path")]
108    InvalidPath(#[from] std::path::StripPrefixError),
109
110    #[error("could not load and compile some templates")]
112    Compile(#[from] minijinja::Error),
113
114    #[error("error from async runtime")]
116    Runtime(#[from] JoinError),
117
118    #[error("missing templates {missing:?}")]
120    MissingTemplates {
121        missing: HashSet<String>,
123        loaded: HashSet<String>,
125    },
126}
127
128fn is_hidden(entry: &DirEntry) -> bool {
129    entry
130        .file_name()
131        .to_str()
132        .is_some_and(|s| s.starts_with('.'))
133}
134
135impl Templates {
136    #[tracing::instrument(
138        name = "templates.load",
139        skip_all,
140        fields(%path),
141    )]
142    pub async fn load(
143        path: Utf8PathBuf,
144        url_builder: UrlBuilder,
145        vite_manifest_path: Utf8PathBuf,
146        translations_path: Utf8PathBuf,
147        branding: SiteBranding,
148        features: SiteFeatures,
149    ) -> Result<Self, TemplateLoadingError> {
150        let (translator, environment) = Self::load_(
151            &path,
152            url_builder.clone(),
153            &vite_manifest_path,
154            &translations_path,
155            branding.clone(),
156            features,
157        )
158        .await?;
159        Ok(Self {
160            environment: Arc::new(ArcSwap::new(environment)),
161            translator: Arc::new(ArcSwap::new(translator)),
162            path,
163            url_builder,
164            vite_manifest_path,
165            translations_path,
166            branding,
167            features,
168        })
169    }
170
171    async fn load_(
172        path: &Utf8Path,
173        url_builder: UrlBuilder,
174        vite_manifest_path: &Utf8Path,
175        translations_path: &Utf8Path,
176        branding: SiteBranding,
177        features: SiteFeatures,
178    ) -> Result<(Arc<Translator>, Arc<minijinja::Environment<'static>>), TemplateLoadingError> {
179        let path = path.to_owned();
180        let span = tracing::Span::current();
181
182        let vite_manifest = tokio::fs::read(vite_manifest_path)
184            .await
185            .map_err(TemplateLoadingError::ViteManifestIO)?;
186
187        let vite_manifest: ViteManifest =
189            serde_json::from_slice(&vite_manifest).map_err(TemplateLoadingError::ViteManifest)?;
190
191        let translations_path = translations_path.to_owned();
192        let translator =
193            tokio::task::spawn_blocking(move || Translator::load_from_path(&translations_path))
194                .await??;
195        let translator = Arc::new(translator);
196
197        debug!(locales = ?translator.available_locales(), "Loaded translations");
198
199        let (loaded, mut env) = tokio::task::spawn_blocking(move || {
200            span.in_scope(move || {
201                let mut loaded: HashSet<_> = HashSet::new();
202                let mut env = minijinja::Environment::new();
203                let root = path.canonicalize_utf8()?;
204                info!(%root, "Loading templates from filesystem");
205                for entry in walkdir::WalkDir::new(&root)
206                    .min_depth(1)
207                    .into_iter()
208                    .filter_entry(|e| !is_hidden(e))
209                {
210                    let entry = entry?;
211                    if entry.file_type().is_file() {
212                        let path = Utf8PathBuf::try_from(entry.into_path())?;
213                        let Some(ext) = path.extension() else {
214                            continue;
215                        };
216
217                        if ext == "html" || ext == "txt" || ext == "subject" {
218                            let relative = path.strip_prefix(&root)?;
219                            debug!(%relative, "Registering template");
220                            let template = std::fs::read_to_string(&path)?;
221                            env.add_template_owned(relative.as_str().to_owned(), template)?;
222                            loaded.insert(relative.as_str().to_owned());
223                        }
224                    }
225                }
226
227                Ok::<_, TemplateLoadingError>((loaded, env))
228            })
229        })
230        .await??;
231
232        env.add_global("branding", Value::from_object(branding));
233        env.add_global("features", Value::from_object(features));
234
235        self::functions::register(
236            &mut env,
237            url_builder,
238            vite_manifest,
239            Arc::clone(&translator),
240        );
241
242        let env = Arc::new(env);
243
244        let needed: HashSet<_> = TEMPLATES.into_iter().map(ToOwned::to_owned).collect();
245        debug!(?loaded, ?needed, "Templates loaded");
246        let missing: HashSet<_> = needed.difference(&loaded).cloned().collect();
247
248        if missing.is_empty() {
249            Ok((translator, env))
250        } else {
251            Err(TemplateLoadingError::MissingTemplates { missing, loaded })
252        }
253    }
254
255    #[tracing::instrument(
257        name = "templates.reload",
258        skip_all,
259        fields(path = %self.path),
260    )]
261    pub async fn reload(&self) -> Result<(), TemplateLoadingError> {
262        let (translator, environment) = Self::load_(
263            &self.path,
264            self.url_builder.clone(),
265            &self.vite_manifest_path,
266            &self.translations_path,
267            self.branding.clone(),
268            self.features,
269        )
270        .await?;
271
272        self.environment.store(environment);
274        self.translator.store(translator);
275
276        Ok(())
277    }
278
279    #[must_use]
281    pub fn translator(&self) -> Arc<Translator> {
282        self.translator.load_full()
283    }
284}
285
286#[derive(Error, Debug)]
288pub enum TemplateError {
289    #[error("missing template {template:?}")]
291    Missing {
292        template: &'static str,
294
295        #[source]
297        source: minijinja::Error,
298    },
299
300    #[error("could not render template {template:?}")]
302    Render {
303        template: &'static str,
305
306        #[source]
308        source: minijinja::Error,
309    },
310}
311
312register_templates! {
313    pub fn render_not_found(WithLanguage<NotFoundContext>) { "pages/404.html" }
315
316    pub fn render_app(WithLanguage<AppContext>) { "app.html" }
318
319    pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
321
322    pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
324
325    pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
327
328    pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
330
331    pub fn render_password_register(WithLanguage<WithCsrf<WithCaptcha<PasswordRegisterContext>>>) { "pages/register/password.html" }
333
334    pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
336
337    pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
339
340    pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
342
343    pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
345
346    pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
348
349    pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
351
352    pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
354
355    pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
357
358    pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
360
361    pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
363
364    pub fn render_recovery_expired(WithLanguage<WithCsrf<RecoveryExpiredContext>>) { "pages/recovery/expired.html" }
366
367    pub fn render_recovery_consumed(WithLanguage<EmptyContext>) { "pages/recovery/consumed.html" }
369
370    pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
372
373    pub fn render_form_post<T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
375
376    pub fn render_error(ErrorContext) { "pages/error.html" }
378
379    pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
381
382    pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
384
385    pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
387
388    pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }
390
391    pub fn render_email_verification_html(WithLanguage<EmailVerificationContext>) { "emails/verification.html" }
393
394    pub fn render_email_verification_subject(WithLanguage<EmailVerificationContext>) { "emails/verification.subject" }
396
397    pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
399
400    pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
402
403    pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
405
406    pub fn render_device_link(WithLanguage<DeviceLinkContext>) { "pages/device_link.html" }
408
409    pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
411
412    pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
414
415    pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
417
418    pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
420
421    pub fn render_device_name(WithLanguage<DeviceNameContext>) { "device_name.txt" }
423}
424
425impl Templates {
426    pub fn check_render(
433        &self,
434        now: chrono::DateTime<chrono::Utc>,
435        rng: &mut impl Rng,
436    ) -> anyhow::Result<()> {
437        check::render_not_found(self, now, rng)?;
438        check::render_app(self, now, rng)?;
439        check::render_swagger(self, now, rng)?;
440        check::render_swagger_callback(self, now, rng)?;
441        check::render_login(self, now, rng)?;
442        check::render_register(self, now, rng)?;
443        check::render_password_register(self, now, rng)?;
444        check::render_register_steps_verify_email(self, now, rng)?;
445        check::render_register_steps_email_in_use(self, now, rng)?;
446        check::render_register_steps_display_name(self, now, rng)?;
447        check::render_consent(self, now, rng)?;
448        check::render_policy_violation(self, now, rng)?;
449        check::render_sso_login(self, now, rng)?;
450        check::render_index(self, now, rng)?;
451        check::render_recovery_start(self, now, rng)?;
452        check::render_recovery_progress(self, now, rng)?;
453        check::render_recovery_finish(self, now, rng)?;
454        check::render_recovery_expired(self, now, rng)?;
455        check::render_recovery_consumed(self, now, rng)?;
456        check::render_recovery_disabled(self, now, rng)?;
457        check::render_form_post::<EmptyContext>(self, now, rng)?;
458        check::render_error(self, now, rng)?;
459        check::render_email_verification_txt(self, now, rng)?;
460        check::render_email_verification_html(self, now, rng)?;
461        check::render_email_verification_subject(self, now, rng)?;
462        check::render_upstream_oauth2_link_mismatch(self, now, rng)?;
463        check::render_upstream_oauth2_suggest_link(self, now, rng)?;
464        check::render_upstream_oauth2_do_register(self, now, rng)?;
465        check::render_device_name(self, now, rng)?;
466        Ok(())
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[tokio::test]
475    async fn check_builtin_templates() {
476        #[allow(clippy::disallowed_methods)]
477        let now = chrono::Utc::now();
478        #[allow(clippy::disallowed_methods)]
479        let mut rng = rand::thread_rng();
480
481        let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/");
482        let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
483        let branding = SiteBranding::new("example.com");
484        let features = SiteFeatures {
485            password_login: true,
486            password_registration: true,
487            account_recovery: true,
488            login_with_email_allowed: true,
489        };
490        let vite_manifest_path =
491            Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
492        let translations_path =
493            Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations");
494        let templates = Templates::load(
495            path,
496            url_builder,
497            vite_manifest_path,
498            translations_path,
499            branding,
500            features,
501        )
502        .await
503        .unwrap();
504        templates.check_render(now, &mut rng).unwrap();
505    }
506}