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