1#![deny(missing_docs)]
9#![allow(clippy::module_name_repetitions)]
10
11use 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#[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#[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 strict: bool,
85}
86
87#[derive(Error, Debug)]
89pub enum TemplateLoadingError {
90 #[error(transparent)]
92 IO(#[from] std::io::Error),
93
94 #[error("failed to read the assets manifest")]
96 ViteManifestIO(#[source] std::io::Error),
97
98 #[error("invalid assets manifest")]
100 ViteManifest(#[from] serde_json::Error),
101
102 #[error("failed to load the translations")]
104 Translations(#[from] mas_i18n::LoadError),
105
106 #[error("failed to traverse the filesystem")]
108 WalkDir(#[from] walkdir::Error),
109
110 #[error("encountered non-UTF-8 path")]
112 NonUtf8Path(#[from] camino::FromPathError),
113
114 #[error("encountered non-UTF-8 path")]
116 NonUtf8PathBuf(#[from] camino::FromPathBufError),
117
118 #[error("encountered invalid path")]
120 InvalidPath(#[from] std::path::StripPrefixError),
121
122 #[error("could not load and compile some templates")]
124 Compile(#[from] minijinja::Error),
125
126 #[error("error from async runtime")]
128 Runtime(#[from] JoinError),
129
130 #[error("missing templates {missing:?}")]
132 MissingTemplates {
133 missing: HashSet<String>,
135 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 #[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 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 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 env.set_undefined_behavior(if strict {
237 UndefinedBehavior::Strict
238 } else {
239 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 #[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 self.environment.store(environment);
320 self.translator.store(translator);
321
322 Ok(())
323 }
324
325 #[must_use]
327 pub fn translator(&self) -> Arc<Translator> {
328 self.translator.load_full()
329 }
330}
331
332#[derive(Error, Debug)]
334pub enum TemplateError {
335 #[error("missing template {template:?}")]
337 Missing {
338 template: &'static str,
340
341 #[source]
343 source: minijinja::Error,
344 },
345
346 #[error("could not render template {template:?}")]
348 Render {
349 template: &'static str,
351
352 #[source]
354 source: minijinja::Error,
355 },
356}
357
358register_templates! {
359 pub fn render_not_found(WithLanguage<NotFoundContext>) { "pages/404.html" }
361
362 pub fn render_app(WithLanguage<AppContext>) { "app.html" }
364
365 pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
367
368 pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
370
371 pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
373
374 pub fn render_register(WithLanguage<WithCsrf<WithCaptcha<RegisterContext>>>) { "pages/register/index.html" }
376
377 pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
379
380 pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
382
383 pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
385
386 pub fn render_register_steps_registration_token(WithLanguage<WithCsrf<RegisterStepsRegistrationTokenContext>>) { "pages/register/steps/registration_token.html" }
388
389 pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
391
392 pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
394
395 pub fn render_compat_login_policy_violation(WithLanguage<WithCsrf<WithSession<CompatLoginPolicyViolationContext>>>) { "pages/compat_login_policy_violation.html" }
397
398 pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
400
401 pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
403
404 pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
406
407 pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
409
410 pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
412
413 pub fn render_recovery_expired(WithLanguage<WithCsrf<RecoveryExpiredContext>>) { "pages/recovery/expired.html" }
415
416 pub fn render_recovery_consumed(WithLanguage<EmptyContext>) { "pages/recovery/consumed.html" }
418
419 pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
421
422 pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
424
425 pub fn render_error(ErrorContext) { "pages/error.html" }
427
428 pub fn render_email_invite_txt(WithLanguage<EmailInviteContext>) { "emails/invite.txt" }
430
431 pub fn render_email_invite_html(WithLanguage<EmailInviteContext>) { "emails/invite.html" }
433
434 pub fn render_email_invite_subject(WithLanguage<EmailInviteContext>) { "emails/invite.subject" }
436
437 pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
439
440 pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
442
443 pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
445
446 pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }
448
449 pub fn render_email_verification_html(WithLanguage<EmailVerificationContext>) { "emails/verification.html" }
451
452 pub fn render_email_verification_subject(WithLanguage<EmailVerificationContext>) { "emails/verification.subject" }
454
455 pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
457
458 pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
460
461 pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
463
464 pub fn render_device_link(WithLanguage<WithCsrf<DeviceLinkContext>>) { "pages/device_link.html" }
466
467 pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
469
470 pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
472
473 pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
475
476 pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
478
479 pub fn render_device_name(WithLanguage<DeviceNameContext>) { "device_name.txt" }
481}
482
483impl Templates {
484 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 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 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 #[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'Brien & Co (@alice:example.com) invited you to ask to join \
582 Q&A <team> 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 #[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}