1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

handlers/templates: infer the language from the Accept-Language browser header

This commit is contained in:
Quentin Gliech
2023-10-04 18:13:57 +02:00
parent 873651a780
commit 1feafc1d13
25 changed files with 253 additions and 76 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -50,6 +50,13 @@ pub struct AcceptLanguage {
parts: Vec<AcceptLanguagePart>,
}
impl AcceptLanguage {
pub fn iter(&self) -> impl Iterator<Item = &Locale> {
// 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

View File

@@ -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" }

View File

@@ -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<AppState> for Templates {
}
}
impl FromRef<AppState> for Arc<Translator> {
fn from_ref(input: &AppState) -> Self {
input.templates.translator()
}
}
impl FromRef<AppState> for Keystore {
fn from_ref(input: &AppState) -> Self {
input.key_store.clone()

View File

@@ -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" }

View File

@@ -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)?;

View File

@@ -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
<B as HttpBody>::Error: std::error::Error + Send + Sync,
S: Clone + Send + Sync + 'static,
UrlBuilder: FromRef<S>,
PreferredLanguage: FromRequestParts<S>,
BoxRepository: FromRequestParts<S>,
CookieJar: FromRequestParts<S>,
BoundActivityTracker: FromRequestParts<S>,
@@ -433,8 +436,9 @@ pub async fn fallback(
OriginalUri(uri): OriginalUri,
method: Method,
version: Version,
PreferredLanguage(locale): PreferredLanguage,
) -> Result<impl IntoResponse, FancyError> {
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)?;

View File

@@ -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<Templates>,
State(url_builder): State<UrlBuilder>,
State(key_store): State<Keystore>,
@@ -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)?;

View File

@@ -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<Templates>,
State(key_store): State<Keystore>,
State(url_builder): State<UrlBuilder>,
@@ -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()

View File

@@ -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<Templates>,
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)?;

View File

@@ -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<S> FromRequestParts<S> for PreferredLanguage
where
S: Send + Sync,
Arc<Translator>: FromRef<S>,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let translator: Arc<Translator> = FromRef::from_ref(state);
let accept_language: Option<TypedHeader<AcceptLanguage>> =
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))
}
}

View File

@@ -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<TestState> for Templates {
}
}
impl FromRef<TestState> for Arc<Translator> {
fn from_ref(input: &TestState) -> Self {
input.templates.translator()
}
}
impl FromRef<TestState> for Keystore {
fn from_ref(input: &TestState) -> Self {
input.key_store.clone()

View File

@@ -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<Templates>,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<headers::UserAgent>>,
@@ -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()
}

View File

@@ -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<Templates>,
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)?;

View File

@@ -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<Templates>,
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)?;

View File

@@ -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<Templates>,
State(password_manager): State<PasswordManager>,
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<PasswordManager>,
State(templates): State<Templates>,
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?;

View File

@@ -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<Templates>,
activity_tracker: BoundActivityTracker,
action: Option<Query<mas_router::AccountAction>>,
@@ -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())

View File

@@ -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<UrlBuilder>,
mut repo: BoxRepository,
cookie_jar: CookieJar,
PreferredLanguage(locale): PreferredLanguage,
) -> Result<impl IntoResponse, FancyError> {
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)?;

View File

@@ -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<PasswordManager>,
State(templates): State<Templates>,
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<PasswordManager>,
State(templates): State<Templates>,
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)

View File

@@ -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<PasswordManager>,
State(templates): State<Templates>,
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)?;

View File

@@ -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<Templates>,
State(password_manager): State<PasswordManager>,
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<PasswordManager>,
State(templates): State<Templates>,
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)

View File

@@ -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),
}
}
}

View File

@@ -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<Self>
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<T> {
lang: String,
#[serde(flatten)]
inner: T,
}
impl<T: TemplateContext> TemplateContext for WithLanguage<T> {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
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<T> {

View File

@@ -40,7 +40,7 @@ pub fn register(
env: &mut minijinja::Environment,
url_builder: UrlBuilder,
vite_manifest: ViteManifest,
translator: Translator,
translator: Arc<Translator>,
) {
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>,
translator: Arc<Translator>,
}
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,

View File

@@ -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<ArcSwap<minijinja::Environment<'static>>>,
translator: Arc<ArcSwap<Translator>>,
url_builder: UrlBuilder,
vite_manifest_path: Utf8PathBuf,
translations_path: Utf8PathBuf,
@@ -151,7 +152,7 @@ impl Templates {
vite_manifest_path: Utf8PathBuf,
translations_path: Utf8PathBuf,
) -> Result<Self, TemplateLoadingError> {
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<minijinja::Environment<'static>, TemplateLoadingError> {
) -> Result<(Arc<Translator>, Arc<minijinja::Environment<'static>>), 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<Translator> {
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<NotFoundContext>) { "pages/404.html" }
/// Render the frontend app
pub fn render_app(AppContext) { "app.html" }
pub fn render_app(WithLanguage<AppContext>) { "app.html" }
/// Render the login page
pub fn render_login(WithCsrf<LoginContext>) { "pages/login.html" }
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
/// Render the registration page
pub fn render_register(WithCsrf<RegisterContext>) { "pages/register.html" }
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register.html" }
/// Render the client consent page
pub fn render_consent(WithCsrf<WithSession<ConsentContext>>) { "pages/consent.html" }
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
/// Render the policy violation page
pub fn render_policy_violation(WithCsrf<WithSession<PolicyViolationContext>>) { "pages/policy_violation.html" }
pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
/// Render the legacy SSO login consent page
pub fn render_sso_login(WithCsrf<WithSession<CompatSsoContext>>) { "pages/sso.html" }
pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }
/// Render the home page
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "pages/index.html" }
pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
/// Render the password change page
pub fn render_account_password(WithCsrf<WithSession<EmptyContext>>) { "pages/account/password.html" }
pub fn render_account_password(WithLanguage<WithCsrf<WithSession<EmptyContext>>>) { "pages/account/password.html" }
/// Render the email verification page
pub fn render_account_verify_email(WithCsrf<WithSession<EmailVerificationPageContext>>) { "pages/account/emails/verify.html" }
pub fn render_account_verify_email(WithLanguage<WithCsrf<WithSession<EmailVerificationPageContext>>>) { "pages/account/emails/verify.html" }
/// Render the email verification page
pub fn render_account_add_email(WithCsrf<WithSession<EmailAddContext>>) { "pages/account/emails/add.html" }
pub fn render_account_add_email(WithLanguage<WithCsrf<WithSession<EmailAddContext>>>) { "pages/account/emails/add.html" }
/// Render the re-authentication form
pub fn render_reauth(WithCsrf<WithSession<ReauthContext>>) { "pages/reauth.html" }
pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" }
/// Render the form used by the form_post response mode
pub fn render_form_post<T: Serialize>(FormPostContext<T>) { "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<WithSession<UpstreamExistingLinkContext>>) { "pages/upstream_oauth2/link_mismatch.html" }
pub fn render_upstream_oauth2_link_mismatch(WithLanguage<WithCsrf<WithSession<UpstreamExistingLinkContext>>>) { "pages/upstream_oauth2/link_mismatch.html" }
/// Render the upstream suggest link message
pub fn render_upstream_oauth2_suggest_link(WithCsrf<WithSession<UpstreamSuggestLink>>) { "pages/upstream_oauth2/suggest_link.html" }
pub fn render_upstream_oauth2_suggest_link(WithLanguage<WithCsrf<WithSession<UpstreamSuggestLink>>>) { "pages/upstream_oauth2/suggest_link.html" }
/// Render the upstream register screen
pub fn render_upstream_oauth2_do_register(WithCsrf<UpstreamRegister>) { "pages/upstream_oauth2/do_register.html" }
pub fn render_upstream_oauth2_do_register(WithLanguage<WithCsrf<UpstreamRegister>>) { "pages/upstream_oauth2/do_register.html" }
}
impl Templates {