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, EmailRecoveryContext,
39 EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
40 LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
41 PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
42 ReauthFormField, 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 err,
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 let vite_manifest = tokio::fs::read(vite_manifest_path)
185 .await
186 .map_err(TemplateLoadingError::ViteManifestIO)?;
187
188 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 #[tracing::instrument(
258 name = "templates.reload",
259 skip_all,
260 fields(path = %self.path),
261 err,
262 )]
263 pub async fn reload(&self) -> Result<(), TemplateLoadingError> {
264 let (translator, environment) = Self::load_(
265 &self.path,
266 self.url_builder.clone(),
267 &self.vite_manifest_path,
268 &self.translations_path,
269 self.branding.clone(),
270 self.features,
271 )
272 .await?;
273
274 self.environment.store(environment);
276 self.translator.store(translator);
277
278 Ok(())
279 }
280
281 #[must_use]
283 pub fn translator(&self) -> Arc<Translator> {
284 self.translator.load_full()
285 }
286}
287
288#[derive(Error, Debug)]
290pub enum TemplateError {
291 #[error("missing template {template:?}")]
293 Missing {
294 template: &'static str,
296
297 #[source]
299 source: minijinja::Error,
300 },
301
302 #[error("could not render template {template:?}")]
304 Render {
305 template: &'static str,
307
308 #[source]
310 source: minijinja::Error,
311 },
312}
313
314register_templates! {
315 pub fn render_not_found(WithLanguage<NotFoundContext>) { "pages/404.html" }
317
318 pub fn render_app(WithLanguage<AppContext>) { "app.html" }
320
321 pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
323
324 pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
326
327 pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
329
330 pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
332
333 pub fn render_password_register(WithLanguage<WithCsrf<WithCaptcha<PasswordRegisterContext>>>) { "pages/register/password.html" }
335
336 pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
338
339 pub fn render_register_steps_email_in_use(WithLanguage<RegisterStepsEmailInUseContext>) { "pages/register/steps/email_in_use.html" }
341
342 pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
344
345 pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
347
348 pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
350
351 pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
353
354 pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
356
357 pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
359
360 pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
362
363 pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
365
366 pub fn render_recovery_expired(WithLanguage<WithCsrf<RecoveryExpiredContext>>) { "pages/recovery/expired.html" }
368
369 pub fn render_recovery_consumed(WithLanguage<EmptyContext>) { "pages/recovery/consumed.html" }
371
372 pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
374
375 pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" }
377
378 pub fn render_form_post<T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
380
381 pub fn render_error(ErrorContext) { "pages/error.html" }
383
384 pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
386
387 pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
389
390 pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
392
393 pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }
395
396 pub fn render_email_verification_html(WithLanguage<EmailVerificationContext>) { "emails/verification.html" }
398
399 pub fn render_email_verification_subject(WithLanguage<EmailVerificationContext>) { "emails/verification.subject" }
401
402 pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
404
405 pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
407
408 pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
410
411 pub fn render_device_link(WithLanguage<DeviceLinkContext>) { "pages/device_link.html" }
413
414 pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
416
417 pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
419
420 pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
422
423 pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
425}
426
427impl Templates {
428 pub fn check_render(
435 &self,
436 now: chrono::DateTime<chrono::Utc>,
437 rng: &mut impl Rng,
438 ) -> anyhow::Result<()> {
439 check::render_not_found(self, now, rng)?;
440 check::render_app(self, now, rng)?;
441 check::render_swagger(self, now, rng)?;
442 check::render_swagger_callback(self, now, rng)?;
443 check::render_login(self, now, rng)?;
444 check::render_register(self, now, rng)?;
445 check::render_password_register(self, now, rng)?;
446 check::render_register_steps_verify_email(self, now, rng)?;
447 check::render_register_steps_email_in_use(self, now, rng)?;
448 check::render_register_steps_display_name(self, now, rng)?;
449 check::render_consent(self, now, rng)?;
450 check::render_policy_violation(self, now, rng)?;
451 check::render_sso_login(self, now, rng)?;
452 check::render_index(self, now, rng)?;
453 check::render_recovery_start(self, now, rng)?;
454 check::render_recovery_progress(self, now, rng)?;
455 check::render_recovery_finish(self, now, rng)?;
456 check::render_recovery_expired(self, now, rng)?;
457 check::render_recovery_consumed(self, now, rng)?;
458 check::render_recovery_disabled(self, now, rng)?;
459 check::render_reauth(self, now, rng)?;
460 check::render_form_post::<EmptyContext>(self, now, rng)?;
461 check::render_error(self, now, rng)?;
462 check::render_email_verification_txt(self, now, rng)?;
463 check::render_email_verification_html(self, now, rng)?;
464 check::render_email_verification_subject(self, now, rng)?;
465 check::render_upstream_oauth2_link_mismatch(self, now, rng)?;
466 check::render_upstream_oauth2_suggest_link(self, now, rng)?;
467 check::render_upstream_oauth2_do_register(self, now, rng)?;
468 Ok(())
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[tokio::test]
477 async fn check_builtin_templates() {
478 #[allow(clippy::disallowed_methods)]
479 let now = chrono::Utc::now();
480 #[allow(clippy::disallowed_methods)]
481 let mut rng = rand::thread_rng();
482
483 let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/");
484 let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
485 let branding = SiteBranding::new("example.com");
486 let features = SiteFeatures {
487 password_login: true,
488 password_registration: true,
489 account_recovery: true,
490 };
491 let vite_manifest_path =
492 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
493 let translations_path =
494 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations");
495 let templates = Templates::load(
496 path,
497 url_builder,
498 vite_manifest_path,
499 translations_path,
500 branding,
501 features,
502 )
503 .await
504 .unwrap();
505 templates.check_render(now, &mut rng).unwrap();
506 }
507}