1#![deny(missing_docs)]
8#![allow(clippy::module_name_repetitions)]
9
10use 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#[must_use]
62pub fn escape_html(input: &str) -> String {
63 v_htmlescape::escape(input).to_string()
64}
65
66#[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: Option<Utf8PathBuf>,
76 translations_path: Utf8PathBuf,
77 path: Utf8PathBuf,
78 strict: bool,
81}
82
83#[derive(Error, Debug)]
85pub enum TemplateLoadingError {
86 #[error(transparent)]
88 IO(#[from] std::io::Error),
89
90 #[error("failed to read the assets manifest")]
92 ViteManifestIO(#[source] std::io::Error),
93
94 #[error("invalid assets manifest")]
96 ViteManifest(#[from] serde_json::Error),
97
98 #[error("failed to load the translations")]
100 Translations(#[from] mas_i18n::LoadError),
101
102 #[error("failed to traverse the filesystem")]
104 WalkDir(#[from] walkdir::Error),
105
106 #[error("encountered non-UTF-8 path")]
108 NonUtf8Path(#[from] camino::FromPathError),
109
110 #[error("encountered non-UTF-8 path")]
112 NonUtf8PathBuf(#[from] camino::FromPathBufError),
113
114 #[error("encountered invalid path")]
116 InvalidPath(#[from] std::path::StripPrefixError),
117
118 #[error("could not load and compile some templates")]
120 Compile(#[from] minijinja::Error),
121
122 #[error("error from async runtime")]
124 Runtime(#[from] JoinError),
125
126 #[error("missing templates {missing:?}")]
128 MissingTemplates {
129 missing: HashSet<String>,
131 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 #[tracing::instrument(
155 name = "templates.load",
156 skip_all,
157 fields(%path),
158 )]
159 pub async fn load(
160 path: Utf8PathBuf,
161 url_builder: UrlBuilder,
162 vite_manifest_path: Option<Utf8PathBuf>,
163 translations_path: Utf8PathBuf,
164 branding: SiteBranding,
165 features: SiteFeatures,
166 strict: bool,
167 ) -> Result<Self, TemplateLoadingError> {
168 let (translator, environment) = Self::load_(
169 &path,
170 url_builder.clone(),
171 vite_manifest_path.as_deref(),
172 &translations_path,
173 branding.clone(),
174 features,
175 strict,
176 )
177 .await?;
178 Ok(Self {
179 environment: Arc::new(ArcSwap::new(environment)),
180 translator: Arc::new(ArcSwap::new(translator)),
181 path,
182 url_builder,
183 vite_manifest_path,
184 translations_path,
185 branding,
186 features,
187 strict,
188 })
189 }
190
191 async fn load_(
192 path: &Utf8Path,
193 url_builder: UrlBuilder,
194 vite_manifest_path: Option<&Utf8Path>,
195 translations_path: &Utf8Path,
196 branding: SiteBranding,
197 features: SiteFeatures,
198 strict: bool,
199 ) -> Result<(Arc<Translator>, Arc<minijinja::Environment<'static>>), TemplateLoadingError> {
200 let path = path.to_owned();
201 let span = tracing::Span::current();
202
203 let vite_manifest = if let Some(vite_manifest_path) = vite_manifest_path {
205 let raw_vite_manifest = tokio::fs::read(vite_manifest_path)
206 .await
207 .map_err(TemplateLoadingError::ViteManifestIO)?;
208
209 Some(
210 serde_json::from_slice::<ViteManifest>(&raw_vite_manifest)
211 .map_err(TemplateLoadingError::ViteManifest)?,
212 )
213 } else {
214 None
215 };
216
217 let translations_path = translations_path.to_owned();
220 let translator =
221 tokio::task::spawn_blocking(move || Translator::load_from_path(&translations_path))
222 .await??;
223 let translator = Arc::new(translator);
224
225 debug!(locales = ?translator.available_locales(), "Loaded translations");
226
227 let (loaded, mut env) = tokio::task::spawn_blocking(move || {
228 span.in_scope(move || {
229 let mut loaded: HashSet<_> = HashSet::new();
230 let mut env = minijinja::Environment::new();
231 env.set_undefined_behavior(if strict {
233 UndefinedBehavior::Strict
234 } else {
235 UndefinedBehavior::SemiStrict
239 });
240 let root = path.canonicalize_utf8()?;
241 info!(%root, "Loading templates from filesystem");
242 for entry in walkdir::WalkDir::new(&root)
243 .min_depth(1)
244 .into_iter()
245 .filter_entry(|e| !is_hidden(e))
246 {
247 let entry = entry?;
248 if entry.file_type().is_file() {
249 let path = Utf8PathBuf::try_from(entry.into_path())?;
250 let Some(ext) = path.extension() else {
251 continue;
252 };
253
254 if ext == "html" || ext == "txt" || ext == "subject" {
255 let relative = path.strip_prefix(&root)?;
256 debug!(%relative, "Registering template");
257 let template = std::fs::read_to_string(&path)?;
258 env.add_template_owned(relative.as_str().to_owned(), template)?;
259 loaded.insert(relative.as_str().to_owned());
260 }
261 }
262 }
263
264 Ok::<_, TemplateLoadingError>((loaded, env))
265 })
266 })
267 .await??;
268
269 env.add_global("branding", Value::from_object(branding));
270 env.add_global("features", Value::from_object(features));
271
272 self::functions::register(
273 &mut env,
274 url_builder,
275 vite_manifest,
276 Arc::clone(&translator),
277 );
278
279 let env = Arc::new(env);
280
281 let needed: HashSet<_> = TEMPLATES.into_iter().map(ToOwned::to_owned).collect();
282 debug!(?loaded, ?needed, "Templates loaded");
283 let missing: HashSet<_> = needed.difference(&loaded).cloned().collect();
284
285 if missing.is_empty() {
286 Ok((translator, env))
287 } else {
288 Err(TemplateLoadingError::MissingTemplates { missing, loaded })
289 }
290 }
291
292 #[tracing::instrument(
298 name = "templates.reload",
299 skip_all,
300 fields(path = %self.path),
301 )]
302 pub async fn reload(&self) -> Result<(), TemplateLoadingError> {
303 let (translator, environment) = Self::load_(
304 &self.path,
305 self.url_builder.clone(),
306 self.vite_manifest_path.as_deref(),
307 &self.translations_path,
308 self.branding.clone(),
309 self.features,
310 self.strict,
311 )
312 .await?;
313
314 self.environment.store(environment);
316 self.translator.store(translator);
317
318 Ok(())
319 }
320
321 #[must_use]
323 pub fn translator(&self) -> Arc<Translator> {
324 self.translator.load_full()
325 }
326}
327
328#[derive(Error, Debug)]
330pub enum TemplateError {
331 #[error("missing template {template:?}")]
333 Missing {
334 template: &'static str,
336
337 #[source]
339 source: minijinja::Error,
340 },
341
342 #[error("could not render template {template:?}")]
344 Render {
345 template: &'static str,
347
348 #[source]
350 source: minijinja::Error,
351 },
352}
353
354register_templates! {
355 pub fn render_not_found(WithLanguage<NotFoundContext>) { "pages/404.html" }
357
358 pub fn render_app(WithLanguage<AppContext>) { "app.html" }
360
361 pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
363
364 pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
366
367 pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
369
370 pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
372
373 pub fn render_password_register(WithLanguage<WithCsrf<WithCaptcha<PasswordRegisterContext>>>) { "pages/register/password.html" }
375
376 pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
378
379 pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
381
382 pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
384
385 pub fn render_register_steps_registration_token(WithLanguage<WithCsrf<RegisterStepsRegistrationTokenContext>>) { "pages/register/steps/registration_token.html" }
387
388 pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
390
391 pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
393
394 pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
396
397 pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
399
400 pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
402
403 pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
405
406 pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
408
409 pub fn render_recovery_expired(WithLanguage<WithCsrf<RecoveryExpiredContext>>) { "pages/recovery/expired.html" }
411
412 pub fn render_recovery_consumed(WithLanguage<EmptyContext>) { "pages/recovery/consumed.html" }
414
415 pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
417
418 pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
420
421 pub fn render_error(ErrorContext) { "pages/error.html" }
423
424 pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
426
427 pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
429
430 pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
432
433 pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }
435
436 pub fn render_email_verification_html(WithLanguage<EmailVerificationContext>) { "emails/verification.html" }
438
439 pub fn render_email_verification_subject(WithLanguage<EmailVerificationContext>) { "emails/verification.subject" }
441
442 pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
444
445 pub fn render_upstream_oauth2_login_link(WithLanguage<WithCsrf<UpstreamExistingLinkContext>>) { "pages/upstream_oauth2/login_link.html" }
447
448 pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
450
451 pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
453
454 pub fn render_device_link(WithLanguage<DeviceLinkContext>) { "pages/device_link.html" }
456
457 pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
459
460 pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
462
463 pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
465
466 pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
468
469 pub fn render_device_name(WithLanguage<DeviceNameContext>) { "device_name.txt" }
471}
472
473impl Templates {
474 pub fn check_render<R: Rng + 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 #[allow(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 };
517 let vite_manifest_path =
518 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
519 let translations_path =
520 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations");
521
522 for use_real_vite_manifest in [true, false] {
523 let templates = Templates::load(
524 path.clone(),
525 url_builder.clone(),
526 use_real_vite_manifest.then_some(vite_manifest_path.clone()),
529 translations_path.clone(),
530 branding.clone(),
531 features,
532 true,
534 )
535 .await
536 .unwrap();
537
538 let render1 = templates.check_render(now, &rng).unwrap();
540 let render2 = templates.check_render(now, &rng).unwrap();
541
542 assert_eq!(render1, render2);
543 }
544 }
545}