From 1feafc1d130c3184f6f872587e9c38ff16e15365 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 4 Oct 2023 18:13:57 +0200 Subject: [PATCH] handlers/templates: infer the language from the Accept-Language browser header --- Cargo.lock | 2 + crates/axum-utils/src/language_detection.rs | 7 ++ crates/cli/Cargo.toml | 1 + crates/cli/src/app_state.rs | 7 ++ crates/handlers/Cargo.toml | 1 + .../handlers/src/compat/login_sso_complete.rs | 6 +- crates/handlers/src/lib.rs | 8 ++- .../src/oauth2/authorization/complete.rs | 8 ++- .../handlers/src/oauth2/authorization/mod.rs | 6 +- crates/handlers/src/oauth2/consent.rs | 9 ++- crates/handlers/src/preferred_language.rs | 53 +++++++++++++++ crates/handlers/src/test_utils.rs | 7 ++ crates/handlers/src/upstream_oauth2/link.rs | 11 +-- .../handlers/src/views/account/emails/add.rs | 6 +- .../src/views/account/emails/verify.rs | 6 +- crates/handlers/src/views/account/password.rs | 21 ++++-- crates/handlers/src/views/app.rs | 7 +- crates/handlers/src/views/index.rs | 7 +- crates/handlers/src/views/login.rs | 11 ++- crates/handlers/src/views/reauth.rs | 8 ++- crates/handlers/src/views/register.rs | 10 ++- crates/i18n-scan/src/key.rs | 9 +-- crates/templates/src/context.rs | 36 ++++++++++ crates/templates/src/functions.rs | 15 ++--- crates/templates/src/lib.rs | 67 ++++++++++++------- 25 files changed, 253 insertions(+), 76 deletions(-) create mode 100644 crates/handlers/src/preferred_language.rs diff --git a/Cargo.lock b/Cargo.lock index f7e44ccc..73138201 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2740,6 +2740,7 @@ dependencies = [ "mas-graphql", "mas-handlers", "mas-http", + "mas-i18n", "mas-iana", "mas-keystore", "mas-listener", @@ -2887,6 +2888,7 @@ dependencies = [ "mas-data-model", "mas-graphql", "mas-http", + "mas-i18n", "mas-iana", "mas-jose", "mas-keystore", diff --git a/crates/axum-utils/src/language_detection.rs b/crates/axum-utils/src/language_detection.rs index 8b907f60..fc0b4006 100644 --- a/crates/axum-utils/src/language_detection.rs +++ b/crates/axum-utils/src/language_detection.rs @@ -50,6 +50,13 @@ pub struct AcceptLanguage { parts: Vec, } +impl AcceptLanguage { + pub fn iter(&self) -> impl Iterator { + // This should stop when we hit the first None, aka the first * + self.parts.iter().map_while(|item| item.locale.as_ref()) + } +} + /// Utility to trim ASCII whitespace from the start and end of a byte slice const fn trim_bytes(mut bytes: &[u8]) -> &[u8] { // Trim leading and trailing whitespace diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3760b8cb..f0d8d7ca 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -53,6 +53,7 @@ mas-email = { path = "../email" } mas-graphql = { path = "../graphql" } mas-handlers = { path = "../handlers", default-features = false } mas-http = { path = "../http", default-features = false, features = ["axum", "client"] } +mas-i18n = { path = "../i18n" } mas-iana = { path = "../iana" } mas-keystore = { path = "../keystore" } mas-listener = { path = "../listener" } diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 9b4497e8..78753d46 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -23,6 +23,7 @@ use mas_handlers::{ passwords::PasswordManager, ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, HttpClientFactory, MatrixHomeserver, MetadataCache, SiteConfig, }; +use mas_i18n::Translator; use mas_keystore::{Encrypter, Keystore}; use mas_policy::{Policy, PolicyFactory}; use mas_router::UrlBuilder; @@ -152,6 +153,12 @@ impl FromRef for Templates { } } +impl FromRef for Arc { + fn from_ref(input: &AppState) -> Self { + input.templates.translator() + } +} + impl FromRef for Keystore { fn from_ref(input: &AppState) -> Self { input.key_store.clone() diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 5d9b8740..40d60732 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -66,6 +66,7 @@ mas-axum-utils = { path = "../axum-utils", default-features = false } mas-data-model = { path = "../data-model" } mas-graphql = { path = "../graphql" } mas-http = { path = "../http", default-features = false } +mas-i18n = { path = "../i18n" } mas-iana = { path = "../iana" } mas-jose = { path = "../jose" } mas-keystore = { path = "../keystore" } diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 94d90f7b..916d2e00 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -37,6 +37,8 @@ use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates}; use serde::{Deserialize, Serialize}; use ulid::Ulid; +use crate::PreferredLanguage; + #[derive(Serialize)] struct AllParams<'s> { #[serde(flatten)] @@ -58,6 +60,7 @@ pub struct Params { err, )] pub async fn get( + PreferredLanguage(locale): PreferredLanguage, mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, @@ -110,7 +113,8 @@ pub async fn get( let ctx = CompatSsoContext::new(login) .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_sso_login(&ctx)?; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 97ea2e41..6b980542 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -53,7 +53,7 @@ use mas_keystore::{Encrypter, Keystore}; use mas_policy::Policy; use mas_router::{Route, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng}; -use mas_templates::{ErrorContext, NotFoundContext, Templates}; +use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates}; use passwords::PasswordManager; use sqlx::PgPool; use tower::util::AndThenLayer; @@ -68,6 +68,7 @@ pub mod upstream_oauth2; mod views; mod activity_tracker; +mod preferred_language; mod site_config; #[cfg(test)] mod test_utils; @@ -96,6 +97,7 @@ pub use self::{ activity_tracker::{ActivityTracker, Bound as BoundActivityTracker}, compat::MatrixHomeserver, graphql::schema as graphql_schema, + preferred_language::PreferredLanguage, site_config::SiteConfig, upstream_oauth2::cache::MetadataCache, }; @@ -298,6 +300,7 @@ where ::Error: std::error::Error + Send + Sync, S: Clone + Send + Sync + 'static, UrlBuilder: FromRef, + PreferredLanguage: FromRequestParts, BoxRepository: FromRequestParts, CookieJar: FromRequestParts, BoundActivityTracker: FromRequestParts, @@ -433,8 +436,9 @@ pub async fn fallback( OriginalUri(uri): OriginalUri, method: Method, version: Version, + PreferredLanguage(locale): PreferredLanguage, ) -> Result { - let ctx = NotFoundContext::new(&method, version, &uri); + let ctx = NotFoundContext::new(&method, version, &uri).with_language(locale); // XXX: this should look at the Accept header and return JSON if requested let res = templates.render_not_found(&ctx)?; diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index 6419cb41..2ca2ba81 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -34,7 +34,9 @@ use tracing::warn; use ulid::Ulid; use super::callback::CallbackDestination; -use crate::{impl_from_error_for_route, oauth2::generate_id_token, BoundActivityTracker}; +use crate::{ + impl_from_error_for_route, oauth2::generate_id_token, BoundActivityTracker, PreferredLanguage, +}; #[derive(Debug, Error)] pub enum RouteError { @@ -89,6 +91,7 @@ impl_from_error_for_route!(super::callback::CallbackDestinationError); pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(url_builder): State, State(key_store): State, @@ -160,7 +163,8 @@ pub(crate) async fn get( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let ctx = PolicyViolationContext::new(grant, client) .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_policy_violation(&ctx)?; diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index f4b9ed8f..c733960f 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -39,7 +39,7 @@ use thiserror::Error; use tracing::warn; use self::{callback::CallbackDestination, complete::GrantCompletionError}; -use crate::{impl_from_error_for_route, BoundActivityTracker}; +use crate::{impl_from_error_for_route, BoundActivityTracker, PreferredLanguage}; mod callback; pub mod complete; @@ -139,6 +139,7 @@ fn resolve_response_mode( pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(key_store): State, State(url_builder): State, @@ -418,7 +419,8 @@ pub(crate) async fn get( let ctx = PolicyViolationContext::new(grant, client) .with_session(user_session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_policy_violation(&ctx)?; Html(content).into_response() diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs index 06fe5075..6e671a32 100644 --- a/crates/handlers/src/oauth2/consent.rs +++ b/crates/handlers/src/oauth2/consent.rs @@ -34,7 +34,7 @@ use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Tem use thiserror::Error; use ulid::Ulid; -use crate::{impl_from_error_for_route, BoundActivityTracker}; +use crate::{impl_from_error_for_route, BoundActivityTracker, PreferredLanguage}; #[derive(Debug, Error)] pub enum RouteError { @@ -82,6 +82,7 @@ impl IntoResponse for RouteError { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, mut policy: Policy, mut repo: BoxRepository, @@ -123,7 +124,8 @@ pub(crate) async fn get( if res.valid() { let ctx = ConsentContext::new(grant, client) .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_consent(&ctx)?; @@ -131,7 +133,8 @@ pub(crate) async fn get( } else { let ctx = PolicyViolationContext::new(grant, client) .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_policy_violation(&ctx)?; diff --git a/crates/handlers/src/preferred_language.rs b/crates/handlers/src/preferred_language.rs new file mode 100644 index 00000000..603a779b --- /dev/null +++ b/crates/handlers/src/preferred_language.rs @@ -0,0 +1,53 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{convert::Infallible, sync::Arc}; + +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::request::Parts, + TypedHeader, +}; +use mas_axum_utils::language_detection::AcceptLanguage; +use mas_i18n::{DataLocale, Translator}; + +pub struct PreferredLanguage(pub DataLocale); + +#[async_trait] +impl FromRequestParts for PreferredLanguage +where + S: Send + Sync, + Arc: FromRef, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let translator: Arc = FromRef::from_ref(state); + let accept_language: Option> = + FromRequestParts::from_request_parts(parts, state).await?; + let supported_language = translator.available_locales(); + + let locale = accept_language + .and_then(|TypedHeader(accept_language)| { + accept_language.iter().find_map(|lang| { + let locale: DataLocale = lang.into(); + supported_language.contains(&&locale).then_some(locale) + }) + }) + .unwrap_or("en".parse().unwrap()); + + Ok(PreferredLanguage(locale)) + } +} diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index fe9b146a..3a9b5a9a 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -33,6 +33,7 @@ use hyper::{ use mas_axum_utils::{ cookies::CookieManager, http_client_factory::HttpClientFactory, ErrorWrapper, }; +use mas_i18n::Translator; use mas_keystore::{Encrypter, JsonWebKey, JsonWebKeySet, Keystore, PrivateKey}; use mas_matrix::{HomeserverConnection, MockHomeserverConnection}; use mas_policy::{InstantiateError, Policy, PolicyFactory}; @@ -319,6 +320,12 @@ impl FromRef for Templates { } } +impl FromRef for Arc { + fn from_ref(input: &TestState) -> Self { + input.templates.translator() + } +} + impl FromRef for Keystore { fn from_ref(input: &TestState) -> Self { input.key_store.clone() diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 7c61e494..b559bc4b 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -42,7 +42,7 @@ use thiserror::Error; use ulid::Ulid; use super::UpstreamSessionsCookie; -use crate::{impl_from_error_for_route, views::shared::OptionalPostAuthAction}; +use crate::{impl_from_error_for_route, views::shared::OptionalPostAuthAction, PreferredLanguage}; #[derive(Debug, Error)] pub(crate) enum RouteError { @@ -189,6 +189,7 @@ pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, mut repo: BoxRepository, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, cookie_jar: CookieJar, user_agent: Option>, @@ -264,7 +265,8 @@ pub(crate) async fn get( let ctx = UpstreamExistingLinkContext::new(user) .with_session(user_session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); Html(templates.render_upstream_oauth2_link_mismatch(&ctx)?).into_response() } @@ -273,7 +275,8 @@ pub(crate) async fn get( // Session not linked, but user logged in: suggest linking account let ctx = UpstreamSuggestLink::new(&link) .with_session(user_session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); Html(templates.render_upstream_oauth2_suggest_link(&ctx)?).into_response() } @@ -358,7 +361,7 @@ pub(crate) async fn get( }, )?; - let ctx = ctx.with_csrf(csrf_token.form_value()); + let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); Html(templates.render_upstream_oauth2_do_register(&ctx)?).into_response() } diff --git a/crates/handlers/src/views/account/emails/add.rs b/crates/handlers/src/views/account/emails/add.rs index 6778ee19..bc463717 100644 --- a/crates/handlers/src/views/account/emails/add.rs +++ b/crates/handlers/src/views/account/emails/add.rs @@ -31,7 +31,7 @@ use mas_storage::{ use mas_templates::{EmailAddContext, ErrorContext, TemplateContext, Templates}; use serde::Deserialize; -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker}; +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; #[derive(Deserialize, Debug)] pub struct EmailForm { @@ -42,6 +42,7 @@ pub struct EmailForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, activity_tracker: BoundActivityTracker, mut repo: BoxRepository, @@ -63,7 +64,8 @@ pub(crate) async fn get( let ctx = EmailAddContext::new() .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_account_add_email(&ctx)?; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs index f432bae2..ad0f5028 100644 --- a/crates/handlers/src/views/account/emails/verify.rs +++ b/crates/handlers/src/views/account/emails/verify.rs @@ -32,7 +32,7 @@ use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; use serde::Deserialize; use ulid::Ulid; -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker}; +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; #[derive(Deserialize, Debug)] pub struct CodeForm { @@ -48,6 +48,7 @@ pub struct CodeForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, activity_tracker: BoundActivityTracker, mut repo: BoxRepository, @@ -84,7 +85,8 @@ pub(crate) async fn get( let ctx = EmailVerificationPageContext::new(user_email) .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_account_verify_email(&ctx)?; diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs index b33c7490..5a591897 100644 --- a/crates/handlers/src/views/account/password.rs +++ b/crates/handlers/src/views/account/password.rs @@ -24,6 +24,7 @@ use mas_axum_utils::{ FancyError, SessionInfoExt, }; use mas_data_model::BrowserSession; +use mas_i18n::DataLocale; use mas_policy::Policy; use mas_router::Route; use mas_storage::{ @@ -35,7 +36,7 @@ use rand::Rng; use serde::Deserialize; use zeroize::Zeroizing; -use crate::{passwords::PasswordManager, BoundActivityTracker}; +use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage}; #[derive(Deserialize)] pub struct ChangeForm { @@ -48,6 +49,7 @@ pub struct ChangeForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(password_manager): State, activity_tracker: BoundActivityTracker, @@ -68,7 +70,7 @@ pub(crate) async fn get( .record_browser_session(&clock, &session) .await; - render(&mut rng, &clock, templates, session, cookie_jar).await + render(&mut rng, &clock, locale, templates, session, cookie_jar).await } else { let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword); Ok((cookie_jar, login.go()).into_response()) @@ -78,6 +80,7 @@ pub(crate) async fn get( async fn render( rng: impl Rng + Send, clock: &impl Clock, + locale: DataLocale, templates: Templates, session: BrowserSession, cookie_jar: CookieJar, @@ -86,7 +89,8 @@ async fn render( let ctx = EmptyContext .with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_account_password(&ctx)?; @@ -97,6 +101,7 @@ async fn render( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, activity_tracker: BoundActivityTracker, @@ -172,7 +177,15 @@ pub(crate) async fn post( .record_browser_session(&clock, &session) .await; - let reply = render(&mut rng, &clock, templates.clone(), session, cookie_jar).await?; + let reply = render( + &mut rng, + &clock, + locale, + templates.clone(), + session, + cookie_jar, + ) + .await?; repo.save().await?; diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index 6f25d936..b88805ee 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -19,12 +19,13 @@ use axum::{ use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt}; use mas_router::{PostAuthAction, Route}; use mas_storage::{BoxClock, BoxRepository}; -use mas_templates::{AppContext, Templates}; +use mas_templates::{AppContext, TemplateContext, Templates}; -use crate::BoundActivityTracker; +use crate::{BoundActivityTracker, PreferredLanguage}; #[tracing::instrument(name = "handlers.views.app.get", skip_all, err)] pub async fn get( + PreferredLanguage(locale): PreferredLanguage, State(templates): State, activity_tracker: BoundActivityTracker, action: Option>, @@ -49,7 +50,7 @@ pub async fn get( .record_browser_session(&clock, &session) .await; - let ctx = AppContext::default(); + let ctx = AppContext::default().with_language(locale); let content = templates.render_app(&ctx)?; Ok((cookie_jar, Html(content)).into_response()) diff --git a/crates/handlers/src/views/index.rs b/crates/handlers/src/views/index.rs index 349d1ed0..80b51419 100644 --- a/crates/handlers/src/views/index.rs +++ b/crates/handlers/src/views/index.rs @@ -21,7 +21,7 @@ use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{IndexContext, TemplateContext, Templates}; -use crate::BoundActivityTracker; +use crate::{preferred_language::PreferredLanguage, BoundActivityTracker}; #[tracing::instrument(name = "handlers.views.index.get", skip_all, err)] pub async fn get( @@ -32,7 +32,9 @@ pub async fn get( State(url_builder): State, mut repo: BoxRepository, cookie_jar: CookieJar, + PreferredLanguage(locale): PreferredLanguage, ) -> Result { + tracing::info!("{locale}"); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (session_info, cookie_jar) = cookie_jar.session_info(); let session = session_info.load_session(&mut repo).await?; @@ -45,7 +47,8 @@ pub async fn get( let ctx = IndexContext::new(url_builder.oidc_discovery()) .maybe_with_session(session) - .with_csrf(csrf_token.form_value()); + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_index(&ctx)?; diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 4e5fda7c..4bacec4e 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -25,6 +25,7 @@ use mas_axum_utils::{ FancyError, SessionInfoExt, }; use mas_data_model::BrowserSession; +use mas_i18n::DataLocale; use mas_router::{Route, UpstreamOAuth2Authorize}; use mas_storage::{ upstream_oauth2::UpstreamOAuthProviderRepository, @@ -39,7 +40,7 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; -use crate::{passwords::PasswordManager, BoundActivityTracker}; +use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage}; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct LoginForm { @@ -55,6 +56,7 @@ impl ToFormState for LoginForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, mut repo: BoxRepository, @@ -93,6 +95,7 @@ pub(crate) async fn get( }; let content = render( + locale, LoginContext::default() // XXX: we might want to have a site-wide config in the templates context instead? .with_password_login(password_manager.is_enabled()) @@ -111,6 +114,7 @@ pub(crate) async fn get( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, mut repo: BoxRepository, @@ -148,6 +152,7 @@ pub(crate) async fn post( if !state.is_valid() { let providers = repo.upstream_oauth_provider().all().await?; let content = render( + locale, LoginContext::default() .with_form_state(state) .with_upstream_providers(providers), @@ -187,6 +192,7 @@ pub(crate) async fn post( let state = state.with_error_on_form(e); let content = render( + locale, LoginContext::default().with_form_state(state), query, csrf_token, @@ -275,6 +281,7 @@ async fn login( } async fn render( + locale: DataLocale, ctx: LoginContext, action: OptionalPostAuthAction, csrf_token: CsrfToken, @@ -287,7 +294,7 @@ async fn render( } else { ctx }; - let ctx = ctx.with_csrf(csrf_token.form_value()); + let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); let content = templates.render_login(&ctx)?; Ok(content) diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 4944d7f4..e5d9c738 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -33,7 +33,7 @@ use serde::Deserialize; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; -use crate::{passwords::PasswordManager, BoundActivityTracker}; +use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage}; #[derive(Deserialize, Debug)] pub(crate) struct ReauthForm { @@ -44,6 +44,7 @@ pub(crate) struct ReauthForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, activity_tracker: BoundActivityTracker, @@ -79,7 +80,10 @@ pub(crate) async fn get( } else { ctx }; - let ctx = ctx.with_session(session).with_csrf(csrf_token.form_value()); + let ctx = ctx + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_reauth(&ctx)?; diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 096585d8..3471e04e 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -27,6 +27,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, CsrfToken, ProtectedForm}, FancyError, SessionInfoExt, }; +use mas_i18n::DataLocale; use mas_policy::Policy; use mas_router::Route; use mas_storage::{ @@ -42,7 +43,7 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; -use crate::{passwords::PasswordManager, BoundActivityTracker}; +use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage}; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct RegisterForm { @@ -60,6 +61,7 @@ impl ToFormState for RegisterForm { pub(crate) async fn get( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(password_manager): State, mut repo: BoxRepository, @@ -84,6 +86,7 @@ pub(crate) async fn get( } let content = render( + locale, RegisterContext::default(), query, csrf_token, @@ -100,6 +103,7 @@ pub(crate) async fn get( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, State(password_manager): State, State(templates): State, mut policy: Policy, @@ -184,6 +188,7 @@ pub(crate) async fn post( if !state.is_valid() { let content = render( + locale, RegisterContext::default().with_form_state(state), query, csrf_token, @@ -238,6 +243,7 @@ pub(crate) async fn post( } async fn render( + locale: DataLocale, ctx: RegisterContext, action: OptionalPostAuthAction, csrf_token: CsrfToken, @@ -250,7 +256,7 @@ async fn render( } else { ctx }; - let ctx = ctx.with_csrf(csrf_token.form_value()); + let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); let content = templates.render_register(&ctx)?; Ok(content) diff --git a/crates/i18n-scan/src/key.rs b/crates/i18n-scan/src/key.rs index 01e39d95..cdb15be4 100644 --- a/crates/i18n-scan/src/key.rs +++ b/crates/i18n-scan/src/key.rs @@ -45,7 +45,7 @@ impl Context { pub fn add_missing(&self, translation_tree: &mut TranslationTree) -> usize { let mut count = 0; for translatable in &self.keys { - let message = Message::from_literal(translatable.default_value()); + let message = Message::from_literal(String::new()); let location = translatable.location.as_ref().map(|location| { if location.span.start_line == location.span.end_line { @@ -123,11 +123,4 @@ impl Key { location: None, } } - - pub fn default_value(&self) -> String { - match self.kind { - Kind::Message => self.key.clone(), - Kind::Plural => format!("%(count)d {}", self.key), - } - } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 02a9f01b..91cb4473 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -20,6 +20,7 @@ use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, UserEmailVerification, }; +use mas_i18n::DataLocale; use mas_router::{PostAuthAction, Route}; use rand::Rng; use serde::{ser::SerializeStruct, Deserialize, Serialize}; @@ -68,6 +69,17 @@ pub trait TemplateContext: Serialize { } } + /// Attach a language to the template context + fn with_language(self, lang: DataLocale) -> WithLanguage + where + Self: Sized, + { + WithLanguage { + lang: lang.to_string(), + inner: self, + } + } + /// Generate sample values for this context type /// /// This is then used to check for template validity in unit tests and in @@ -86,6 +98,30 @@ impl TemplateContext for () { } } +/// Context with a specified locale in it +#[derive(Serialize)] +pub struct WithLanguage { + lang: String, + + #[serde(flatten)] + inner: T, +} + +impl TemplateContext for WithLanguage { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + T::sample(now, rng) + .into_iter() + .map(|inner| WithLanguage { + lang: "en".into(), + inner, + }) + .collect() + } +} + /// Context with a CSRF token in it #[derive(Serialize)] pub struct WithCsrf { diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index edafcaf6..8deb65d3 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -40,7 +40,7 @@ pub fn register( env: &mut minijinja::Environment, url_builder: UrlBuilder, vite_manifest: ViteManifest, - translator: Translator, + translator: Arc, ) { env.add_test("empty", self::tester_empty); env.add_test("starting_with", tester_starting_with); @@ -56,12 +56,7 @@ pub fn register( vite_manifest, }), ); - env.add_global( - "_", - Value::from_object(Translate { - translations: Arc::new(translator), - }), - ); + env.add_global("_", Value::from_object(Translate { translator })); } fn tester_empty(seq: &dyn SeqObject) -> bool { @@ -195,7 +190,7 @@ fn function_add_params_to_url( } struct Translate { - translations: Arc, + translator: Arc, } impl std::fmt::Debug for Translate { @@ -231,14 +226,14 @@ impl Object for Translate { let (key, kwargs): (&str, Kwargs) = from_args(args)?; let (message, _locale) = if let Some(count) = kwargs.get("count")? { - self.translations + self.translator .plural_with_fallback(lang, key, count) .ok_or(Error::new( ErrorKind::InvalidOperation, "Missing translation", ))? } else { - self.translations + self.translator .message_with_fallback(lang, key) .ok_or(Error::new( ErrorKind::InvalidOperation, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 42fd242b..b1ed4cb6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -29,7 +29,7 @@ use std::{collections::HashSet, sync::Arc}; use anyhow::Context as _; use arc_swap::ArcSwap; use camino::{Utf8Path, Utf8PathBuf}; -use mas_i18n::Translator; +use mas_i18n::{Translator}; use mas_router::UrlBuilder; use mas_spa::ViteManifest; use rand::Rng; @@ -53,7 +53,7 @@ pub use self::{ LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink, - WithCsrf, WithOptionalSession, WithSession, + WithCsrf, WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -71,6 +71,7 @@ pub fn escape_html(input: &str) -> String { #[derive(Debug, Clone)] pub struct Templates { environment: Arc>>, + translator: Arc>, url_builder: UrlBuilder, vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, @@ -151,7 +152,7 @@ impl Templates { vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, ) -> Result { - let environment = Self::load_( + let (translator, environment) = Self::load_( &path, url_builder.clone(), &vite_manifest_path, @@ -159,7 +160,8 @@ impl Templates { ) .await?; Ok(Self { - environment: Arc::new(ArcSwap::from_pointee(environment)), + environment: Arc::new(ArcSwap::new(environment)), + translator: Arc::new(ArcSwap::new(translator)), path, url_builder, vite_manifest_path, @@ -172,7 +174,7 @@ impl Templates { url_builder: UrlBuilder, vite_manifest_path: &Utf8Path, translations_path: &Utf8Path, - ) -> Result, TemplateLoadingError> { + ) -> Result<(Arc, Arc>), TemplateLoadingError> { let path = path.to_owned(); let span = tracing::Span::current(); @@ -189,6 +191,7 @@ impl Templates { let translator = tokio::task::spawn_blocking(move || Translator::load_from_path(&translations_path)) .await??; + let translator = Arc::new(translator); let (loaded, mut env) = tokio::task::spawn_blocking(move || { span.in_scope(move || { @@ -223,14 +226,21 @@ impl Templates { }) .await??; - self::functions::register(&mut env, url_builder, vite_manifest, translator); + self::functions::register( + &mut env, + url_builder, + vite_manifest, + Arc::clone(&translator), + ); + + let env = Arc::new(env); let needed: HashSet<_> = TEMPLATES.into_iter().map(ToOwned::to_owned).collect(); debug!(?loaded, ?needed, "Templates loaded"); let missing: HashSet<_> = needed.difference(&loaded).cloned().collect(); if missing.is_empty() { - Ok(env) + Ok((translator, env)) } else { Err(TemplateLoadingError::MissingTemplates { missing, loaded }) } @@ -244,7 +254,7 @@ impl Templates { err, )] pub async fn reload(&self) -> Result<(), TemplateLoadingError> { - let new_minijinja = Self::load_( + let (translator, environment) = Self::load_( &self.path, self.url_builder.clone(), &self.vite_manifest_path, @@ -252,11 +262,18 @@ impl Templates { ) .await?; - // Swap it - self.environment.store(Arc::new(new_minijinja)); + // Swap them + self.environment.store(environment); + self.translator.store(translator); Ok(()) } + + /// Get the translator + #[must_use] + pub fn translator(&self) -> Arc { + self.translator.load_full() + } } /// Failed to render a template @@ -287,40 +304,40 @@ pub enum TemplateError { register_templates! { /// Render the not found fallback page - pub fn render_not_found(NotFoundContext) { "pages/404.html" } + pub fn render_not_found(WithLanguage) { "pages/404.html" } /// Render the frontend app - pub fn render_app(AppContext) { "app.html" } + pub fn render_app(WithLanguage) { "app.html" } /// Render the login page - pub fn render_login(WithCsrf) { "pages/login.html" } + pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithCsrf) { "pages/register.html" } + pub fn render_register(WithLanguage>) { "pages/register.html" } /// Render the client consent page - pub fn render_consent(WithCsrf>) { "pages/consent.html" } + pub fn render_consent(WithLanguage>>) { "pages/consent.html" } /// Render the policy violation page - pub fn render_policy_violation(WithCsrf>) { "pages/policy_violation.html" } + pub fn render_policy_violation(WithLanguage>>) { "pages/policy_violation.html" } /// Render the legacy SSO login consent page - pub fn render_sso_login(WithCsrf>) { "pages/sso.html" } + pub fn render_sso_login(WithLanguage>>) { "pages/sso.html" } /// Render the home page - pub fn render_index(WithCsrf>) { "pages/index.html" } + pub fn render_index(WithLanguage>>) { "pages/index.html" } /// Render the password change page - pub fn render_account_password(WithCsrf>) { "pages/account/password.html" } + pub fn render_account_password(WithLanguage>>) { "pages/account/password.html" } /// Render the email verification page - pub fn render_account_verify_email(WithCsrf>) { "pages/account/emails/verify.html" } + pub fn render_account_verify_email(WithLanguage>>) { "pages/account/emails/verify.html" } /// Render the email verification page - pub fn render_account_add_email(WithCsrf>) { "pages/account/emails/add.html" } + pub fn render_account_add_email(WithLanguage>>) { "pages/account/emails/add.html" } /// Render the re-authentication form - pub fn render_reauth(WithCsrf>) { "pages/reauth.html" } + pub fn render_reauth(WithLanguage>>) { "pages/reauth.html" } /// Render the form used by the form_post response mode pub fn render_form_post(FormPostContext) { "form_post.html" } @@ -338,13 +355,13 @@ register_templates! { pub fn render_email_verification_subject(EmailVerificationContext) { "emails/verification.subject" } /// Render the upstream link mismatch message - pub fn render_upstream_oauth2_link_mismatch(WithCsrf>) { "pages/upstream_oauth2/link_mismatch.html" } + pub fn render_upstream_oauth2_link_mismatch(WithLanguage>>) { "pages/upstream_oauth2/link_mismatch.html" } /// Render the upstream suggest link message - pub fn render_upstream_oauth2_suggest_link(WithCsrf>) { "pages/upstream_oauth2/suggest_link.html" } + pub fn render_upstream_oauth2_suggest_link(WithLanguage>>) { "pages/upstream_oauth2/suggest_link.html" } /// Render the upstream register screen - pub fn render_upstream_oauth2_do_register(WithCsrf) { "pages/upstream_oauth2/do_register.html" } + pub fn render_upstream_oauth2_do_register(WithLanguage>) { "pages/upstream_oauth2/do_register.html" } } impl Templates {