diff --git a/crates/axum-utils/src/language_detection.rs b/crates/axum-utils/src/language_detection.rs index fc0b4006..f0e25092 100644 --- a/crates/axum-utils/src/language_detection.rs +++ b/crates/axum-utils/src/language_detection.rs @@ -30,9 +30,7 @@ struct AcceptLanguagePart { impl PartialOrd for AcceptLanguagePart { fn partial_cmp(&self, other: &Self) -> Option { - // When comparing two AcceptLanguage structs, we only consider the - // quality, in reverse. - Reverse(self.quality).partial_cmp(&Reverse(other.quality)) + Some(self.cmp(other)) } } diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index dde95638..7bc9ddab 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -93,7 +93,13 @@ impl Options { ); // Load and compile the templates - let templates = templates_from_config(&config.templates, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &config.branding, + &url_builder, + &config.matrix.homeserver, + ) + .await?; let http_client_factory = HttpClientFactory::new().await?; diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index e2133309..35f1d978 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -13,7 +13,7 @@ // limitations under the License. use clap::Parser; -use mas_config::TemplatesConfig; +use mas_config::{BrandingConfig, MatrixConfig, TemplatesConfig}; use mas_storage::{Clock, SystemClock}; use rand::SeedableRng; use tracing::info_span; @@ -39,13 +39,22 @@ impl Options { SC::Check => { let _span = info_span!("cli.templates.check").entered(); - let config: TemplatesConfig = root.load_config()?; + let template_config: TemplatesConfig = root.load_config()?; + let branding_config: BrandingConfig = root.load_config()?; + let matrix_config: MatrixConfig = root.load_config()?; + let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy let mut rng = rand_chacha::ChaChaRng::from_entropy(); let url_builder = mas_router::UrlBuilder::new("https://example.com/".parse()?, None, None); - let templates = templates_from_config(&config, &url_builder).await?; + let templates = templates_from_config( + &template_config, + &branding_config, + &url_builder, + &matrix_config.homeserver, + ) + .await?; templates.check_render(clock.now(), &mut rng)?; Ok(()) diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 25e2bce7..96ccc8ec 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -44,7 +44,13 @@ impl Options { ); // Load and compile the templates - let templates = templates_from_config(&config.templates, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &config.branding, + &url_builder, + &config.matrix.homeserver, + ) + .await?; let mailer = mailer_from_config(&config.email, &templates)?; mailer.test_connection().await?; diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index e0ad6965..de808df6 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -16,14 +16,14 @@ use std::time::Duration; use anyhow::Context; use mas_config::{ - DatabaseConfig, DatabaseConnectConfig, EmailConfig, EmailSmtpMode, EmailTransportConfig, - PasswordsConfig, PolicyConfig, TemplatesConfig, + BrandingConfig, DatabaseConfig, DatabaseConnectConfig, EmailConfig, EmailSmtpMode, + EmailTransportConfig, PasswordsConfig, PolicyConfig, TemplatesConfig, }; use mas_email::{MailTransport, Mailer}; use mas_handlers::{passwords::PasswordManager, ActivityTracker}; use mas_policy::PolicyFactory; use mas_router::UrlBuilder; -use mas_templates::{TemplateLoadingError, Templates}; +use mas_templates::{SiteBranding, TemplateLoadingError, Templates}; use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, ConnectOptions, PgConnection, PgPool, @@ -116,13 +116,34 @@ pub async fn policy_factory_from_config( pub async fn templates_from_config( config: &TemplatesConfig, + branding: &BrandingConfig, url_builder: &UrlBuilder, + server_name: &str, ) -> Result { + let mut site_branding = SiteBranding::new(server_name); + + if let Some(service_name) = branding.service_name.as_deref() { + site_branding = site_branding.with_service_name(service_name); + } + + if let Some(policy_uri) = &branding.policy_uri { + site_branding = site_branding.with_policy_uri(policy_uri.as_str()); + } + + if let Some(tos_uri) = &branding.tos_uri { + site_branding = site_branding.with_tos_uri(tos_uri.as_str()); + } + + if let Some(imprint) = branding.imprint.as_deref() { + site_branding = site_branding.with_imprint(imprint); + } + Templates::load( config.path.clone(), url_builder.clone(), config.assets_manifest.clone(), config.translations_path.clone(), + site_branding, ) .await } diff --git a/crates/config/src/sections/branding.rs b/crates/config/src/sections/branding.rs new file mode 100644 index 00000000..7b6c060b --- /dev/null +++ b/crates/config/src/sections/branding.rs @@ -0,0 +1,63 @@ +// 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 async_trait::async_trait; +use rand::Rng; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::ConfigurationSection; + +/// Configuration section for tweaking the branding of the service +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Default)] +pub struct BrandingConfig { + /// A human-readable name. Defaults to the server's address. + pub service_name: Option, + + /// Link to a privacy policy, displayed in the footer of web pages and + /// emails. It is also advertised to clients through the `op_policy_uri` + /// OIDC provider metadata. + pub policy_uri: Option, + + /// Link to a terms of service document, displayed in the footer of web + /// pages and emails. It is also advertised to clients through the + /// `op_tos_uri` OIDC provider metadata. + pub tos_uri: Option, + + /// Legal imprint, displayed in the footer in the footer of web pages and + /// emails. + pub imprint: Option, + + /// Logo displayed in some web pages. + pub logo_uri: Option, +} + +#[async_trait] +impl ConfigurationSection for BrandingConfig { + fn path() -> &'static str { + "branding" + } + + async fn generate(_rng: R) -> anyhow::Result + where + R: Rng + Send, + { + Ok(Self::default()) + } + + fn test() -> Self { + Self::default() + } +} diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 91ec41eb..f0121301 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -17,6 +17,7 @@ use rand::Rng; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +mod branding; mod clients; mod database; mod email; @@ -31,6 +32,7 @@ mod templates; mod upstream_oauth2; pub use self::{ + branding::BrandingConfig, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, database::{ConnectConfig as DatabaseConnectConfig, DatabaseConfig}, email::{EmailConfig, EmailSmtpMode, EmailTransportConfig}, @@ -103,6 +105,10 @@ pub struct RootConfig { #[serde(default)] pub upstream_oauth2: UpstreamOAuth2Config, + /// Configuration section for tweaking the branding of the service + #[serde(default)] + pub branding: BrandingConfig, + /// Experimental configuration options #[serde(default)] pub experimental: ExperimentalConfig, @@ -130,6 +136,7 @@ impl ConfigurationSection for RootConfig { matrix: MatrixConfig::generate(&mut rng).await?, policy: PolicyConfig::generate(&mut rng).await?, upstream_oauth2: UpstreamOAuth2Config::generate(&mut rng).await?, + branding: BrandingConfig::generate(&mut rng).await?, experimental: ExperimentalConfig::generate(&mut rng).await?, }) } @@ -147,6 +154,7 @@ impl ConfigurationSection for RootConfig { matrix: MatrixConfig::test(), policy: PolicyConfig::test(), upstream_oauth2: UpstreamOAuth2Config::test(), + branding: BrandingConfig::test(), experimental: ExperimentalConfig::test(), } } @@ -178,6 +186,9 @@ pub struct AppConfig { #[serde(default)] pub policy: PolicyConfig, + #[serde(default)] + pub branding: BrandingConfig, + #[serde(default)] pub experimental: ExperimentalConfig, } @@ -201,6 +212,7 @@ impl ConfigurationSection for AppConfig { secrets: SecretsConfig::generate(&mut rng).await?, matrix: MatrixConfig::generate(&mut rng).await?, policy: PolicyConfig::generate(&mut rng).await?, + branding: BrandingConfig::generate(&mut rng).await?, experimental: ExperimentalConfig::generate(&mut rng).await?, }) } @@ -215,6 +227,7 @@ impl ConfigurationSection for AppConfig { secrets: SecretsConfig::test(), matrix: MatrixConfig::test(), policy: PolicyConfig::test(), + branding: BrandingConfig::test(), experimental: ExperimentalConfig::test(), } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 3a9b5a9a..cceb9727 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -40,7 +40,7 @@ use mas_policy::{InstantiateError, Policy, PolicyFactory}; use mas_router::{SimpleRoute, UrlBuilder}; use mas_storage::{clock::MockClock, BoxClock, BoxRepository, BoxRng, Repository}; use mas_storage_pg::{DatabaseError, PgRepository}; -use mas_templates::Templates; +use mas_templates::{SiteBranding, Templates}; use rand::SeedableRng; use rand_chacha::ChaChaRng; use serde::{de::DeserializeOwned, Serialize}; @@ -116,11 +116,14 @@ impl TestState { let url_builder = UrlBuilder::new("https://example.com/".parse()?, None, None); + let site_branding = SiteBranding::new("example.com").with_service_name("Example"); + let templates = Templates::load( workspace_root.join("templates"), url_builder.clone(), workspace_root.join("frontend/dist/manifest.json"), workspace_root.join("translations"), + site_branding, ) .await?; diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 0f8fe46a..d5d1ff01 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -54,8 +54,8 @@ impl SynapseConnection { .uri( self.endpoint .join(url) - .map(Url::into) - .unwrap_or(String::new()), + .map(String::from) + .unwrap_or_default(), ) .header(AUTHORIZATION, format!("Bearer {}", self.access_token)) } diff --git a/crates/router/src/traits.rs b/crates/router/src/traits.rs index 3c395477..82b51711 100644 --- a/crates/router/src/traits.rs +++ b/crates/router/src/traits.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::borrow::{Borrow, Cow}; +use std::borrow::Cow; use serde::Serialize; use url::Url; @@ -41,7 +41,7 @@ pub trait Route { fn absolute_url(&self, base: &Url) -> Url { let relative = self.path_and_query(); let relative = relative.trim_start_matches('/'); - base.join(relative.borrow()).unwrap() + base.join(relative).unwrap() } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 54021f9c..3c5a6933 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -14,6 +14,8 @@ //! Contexts used in templates +mod branding; + use std::fmt::Formatter; use chrono::{DateTime, Utc}; @@ -29,6 +31,7 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize}; use ulid::Ulid; use url::Url; +pub use self::branding::SiteBranding; use crate::{FormField, FormState}; /// Helper trait to construct context wrappers diff --git a/crates/templates/src/context/branding.rs b/crates/templates/src/context/branding.rs new file mode 100644 index 00000000..afa28925 --- /dev/null +++ b/crates/templates/src/context/branding.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use minijinja::{value::StructObject, Value}; + +/// Site branding information. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SiteBranding { + server_name: Arc, + service_name: Option>, + policy_uri: Option>, + tos_uri: Option>, + imprint: Option>, + logo_uri: Option>, +} + +impl SiteBranding { + /// Create a new site branding based on the given server name. + #[must_use] + pub fn new(server_name: impl Into>) -> Self { + Self { + server_name: server_name.into(), + service_name: None, + policy_uri: None, + tos_uri: None, + imprint: None, + logo_uri: None, + } + } + + /// Set the service name. + #[must_use] + pub fn with_service_name(mut self, service_name: impl Into>) -> Self { + self.service_name = Some(service_name.into()); + self + } + + /// Set the policy URI. + #[must_use] + pub fn with_policy_uri(mut self, policy_uri: impl Into>) -> Self { + self.policy_uri = Some(policy_uri.into()); + self + } + + /// Set the terms of service URI. + #[must_use] + pub fn with_tos_uri(mut self, tos_uri: impl Into>) -> Self { + self.tos_uri = Some(tos_uri.into()); + self + } + + /// Set the imprint. + #[must_use] + pub fn with_imprint(mut self, imprint: impl Into>) -> Self { + self.imprint = Some(imprint.into()); + self + } + + /// Set the logo URI. + #[must_use] + pub fn with_logo_uri(mut self, logo_uri: impl Into>) -> Self { + self.logo_uri = Some(logo_uri.into()); + self + } +} + +impl StructObject for SiteBranding { + fn get_field(&self, name: &str) -> Option { + match name { + "server_name" => Some(self.server_name.clone().into()), + "service_name" => self.service_name.clone().map(Value::from), + "policy_uri" => self.policy_uri.clone().map(Value::from), + "tos_uri" => self.tos_uri.clone().map(Value::from), + "imprint" => self.imprint.clone().map(Value::from), + "logo_uri" => self.logo_uri.clone().map(Value::from), + _ => None, + } + } + + fn static_fields(&self) -> Option<&'static [&'static str]> { + Some(&[ + "server_name", + "service_name", + "policy_uri", + "tos_uri", + "imprint", + "logo_uri", + ]) + } +} diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 5df20b83..7b3b8786 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -32,6 +32,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use mas_i18n::Translator; use mas_router::UrlBuilder; use mas_spa::ViteManifest; +use minijinja::Value; use rand::Rng; use serde::Serialize; use thiserror::Error; @@ -52,8 +53,8 @@ pub use self::{ EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, - TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink, - WithCsrf, WithLanguage, WithOptionalSession, WithSession, + SiteBranding, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, + UpstreamSuggestLink, WithCsrf, WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -73,6 +74,7 @@ pub struct Templates { environment: Arc>>, translator: Arc>, url_builder: UrlBuilder, + branding: SiteBranding, vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, path: Utf8PathBuf, @@ -151,12 +153,14 @@ impl Templates { url_builder: UrlBuilder, vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, + branding: SiteBranding, ) -> Result { let (translator, environment) = Self::load_( &path, url_builder.clone(), &vite_manifest_path, &translations_path, + branding.clone(), ) .await?; Ok(Self { @@ -166,6 +170,7 @@ impl Templates { url_builder, vite_manifest_path, translations_path, + branding, }) } @@ -174,6 +179,7 @@ impl Templates { url_builder: UrlBuilder, vite_manifest_path: &Utf8Path, translations_path: &Utf8Path, + branding: SiteBranding, ) -> Result<(Arc, Arc>), TemplateLoadingError> { let path = path.to_owned(); let span = tracing::Span::current(); @@ -226,6 +232,8 @@ impl Templates { }) .await??; + env.add_global("branding", Value::from_struct_object(branding)); + self::functions::register( &mut env, url_builder, @@ -259,6 +267,7 @@ impl Templates { self.url_builder.clone(), &self.vite_manifest_path, &self.translations_path, + self.branding.clone(), ) .await?; @@ -409,13 +418,20 @@ mod tests { let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/"); let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); + let branding = SiteBranding::new("example.com").with_service_name("Example"); let vite_manifest_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); let translations_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations"); - let templates = Templates::load(path, url_builder, vite_manifest_path, translations_path) - .await - .unwrap(); + let templates = Templates::load( + path, + url_builder, + vite_manifest_path, + translations_path, + branding, + ) + .await + .unwrap(); templates.check_render(now, &mut rng).unwrap(); } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 68a29a50..5c0a830a 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -8,6 +8,21 @@ "secrets" ], "properties": { + "branding": { + "description": "Configuration section for tweaking the branding of the service", + "default": { + "imprint": null, + "logo_uri": null, + "policy_uri": null, + "service_name": null, + "tos_uri": null + }, + "allOf": [ + { + "$ref": "#/definitions/BrandingConfig" + } + ] + }, "clients": { "description": "List of OAuth 2.0/OIDC clients config", "default": [], @@ -299,6 +314,35 @@ } ] }, + "BrandingConfig": { + "description": "Configuration section for tweaking the branding of the service", + "type": "object", + "properties": { + "imprint": { + "description": "Legal imprint, displayed in the footer in the footer of web pages and emails.", + "type": "string" + }, + "logo_uri": { + "description": "Logo displayed in some web pages.", + "type": "string", + "format": "uri" + }, + "policy_uri": { + "description": "Link to a privacy policy, displayed in the footer of web pages and emails. It is also advertised to clients through the `op_policy_uri` OIDC provider metadata.", + "type": "string", + "format": "uri" + }, + "service_name": { + "description": "A human-readable name. Defaults to the server's address.", + "type": "string" + }, + "tos_uri": { + "description": "Link to a terms of service document, displayed in the footer of web pages and emails. It is also advertised to clients through the `op_tos_uri` OIDC provider metadata.", + "type": "string", + "format": "uri" + } + } + }, "ClaimsImports": { "description": "How claims should be imported", "type": "object", diff --git a/frontend/index.html b/frontend/index.html index ba693ada..d274775b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,7 +23,7 @@ limitations under the License. matrix-authentication-service