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, RecoveryExpiredContext,
42 RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
43 RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
44 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
422impl Templates {
423 pub fn check_render(
430 &self,
431 now: chrono::DateTime<chrono::Utc>,
432 rng: &mut impl Rng,
433 ) -> anyhow::Result<()> {
434 check::render_not_found(self, now, rng)?;
435 check::render_app(self, now, rng)?;
436 check::render_swagger(self, now, rng)?;
437 check::render_swagger_callback(self, now, rng)?;
438 check::render_login(self, now, rng)?;
439 check::render_register(self, now, rng)?;
440 check::render_password_register(self, now, rng)?;
441 check::render_register_steps_verify_email(self, now, rng)?;
442 check::render_register_steps_email_in_use(self, now, rng)?;
443 check::render_register_steps_display_name(self, now, rng)?;
444 check::render_consent(self, now, rng)?;
445 check::render_policy_violation(self, now, rng)?;
446 check::render_sso_login(self, now, rng)?;
447 check::render_index(self, now, rng)?;
448 check::render_recovery_start(self, now, rng)?;
449 check::render_recovery_progress(self, now, rng)?;
450 check::render_recovery_finish(self, now, rng)?;
451 check::render_recovery_expired(self, now, rng)?;
452 check::render_recovery_consumed(self, now, rng)?;
453 check::render_recovery_disabled(self, now, rng)?;
454 check::render_form_post::<EmptyContext>(self, now, rng)?;
455 check::render_error(self, now, rng)?;
456 check::render_email_verification_txt(self, now, rng)?;
457 check::render_email_verification_html(self, now, rng)?;
458 check::render_email_verification_subject(self, now, rng)?;
459 check::render_upstream_oauth2_link_mismatch(self, now, rng)?;
460 check::render_upstream_oauth2_suggest_link(self, now, rng)?;
461 check::render_upstream_oauth2_do_register(self, now, rng)?;
462 Ok(())
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[tokio::test]
471 async fn check_builtin_templates() {
472 #[allow(clippy::disallowed_methods)]
473 let now = chrono::Utc::now();
474 #[allow(clippy::disallowed_methods)]
475 let mut rng = rand::thread_rng();
476
477 let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/");
478 let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
479 let branding = SiteBranding::new("example.com");
480 let features = SiteFeatures {
481 password_login: true,
482 password_registration: true,
483 account_recovery: true,
484 login_with_email_allowed: true,
485 };
486 let vite_manifest_path =
487 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
488 let translations_path =
489 Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations");
490 let templates = Templates::load(
491 path,
492 url_builder,
493 vite_manifest_path,
494 translations_path,
495 branding,
496 features,
497 )
498 .await
499 .unwrap();
500 templates.check_render(now, &mut rng).unwrap();
501 }
502}