From 146a7e90052eb45430db363d010c8f35fc16a9a3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 5 Aug 2021 14:43:42 +0200 Subject: [PATCH] Strongly-typed templates --- .../src/handlers/views/index.rs | 11 +- .../src/handlers/views/login.rs | 14 +- .../src/handlers/views/reauth.rs | 10 +- .../src/templates.rs | 158 ++++++++++++++---- 4 files changed, 134 insertions(+), 59 deletions(-) diff --git a/matrix-authentication-service/src/handlers/views/index.rs b/matrix-authentication-service/src/handlers/views/index.rs index 3949d040f..cd8b8c0cb 100644 --- a/matrix-authentication-service/src/handlers/views/index.rs +++ b/matrix-authentication-service/src/handlers/views/index.rs @@ -17,14 +17,13 @@ use warp::{filters::BoxedFilter, reply::with_header, wrap_fn, Filter, Rejection, use crate::{ config::{CookiesConfig, CsrfConfig}, - errors::WrapError, filters::{ csrf::{save_csrf_token, updated_csrf_token}, session::with_optional_session, with_templates, CsrfToken, }, storage::SessionInfo, - templates::{CommonContext, Templates}, + templates::{TemplateContext, Templates}, }; pub(super) fn filter( @@ -49,13 +48,9 @@ async fn get( csrf_token: CsrfToken, session: Option, ) -> Result<(CsrfToken, impl Reply), Rejection> { - let ctx = CommonContext::default() - .with_csrf_token(&csrf_token) - .maybe_with_session(session) - .finish() - .wrap_error()?; + let ctx = ().maybe_with_session(session).with_csrf(&csrf_token); - let content = templates.render("index.html", &ctx).wrap_error()?; + let content = templates.render_index(&ctx)?; Ok(( csrf_token, with_header(content, "Content-Type", "text/html"), diff --git a/matrix-authentication-service/src/handlers/views/login.rs b/matrix-authentication-service/src/handlers/views/login.rs index cb3108d75..eef7f182a 100644 --- a/matrix-authentication-service/src/handlers/views/login.rs +++ b/matrix-authentication-service/src/handlers/views/login.rs @@ -23,11 +23,11 @@ use crate::{ errors::WrapError, filters::{ csrf::{protected_form, save_csrf_token, updated_csrf_token}, - session::{save_session, with_optional_session}, + session::save_session, with_pool, with_templates, CsrfToken, }, storage::{login, SessionInfo}, - templates::{CommonContext, Templates}, + templates::{TemplateContext, Templates}, }; #[derive(Deserialize)] @@ -45,7 +45,6 @@ pub(super) fn filter( let get = warp::get() .and(with_templates(templates)) .and(updated_csrf_token(cookies_config, csrf_config)) - .and(with_optional_session(pool, cookies_config)) .and_then(get) .untuple_one() .with(wrap_fn(save_csrf_token(cookies_config))); @@ -63,16 +62,11 @@ pub(super) fn filter( async fn get( templates: Templates, csrf_token: CsrfToken, - session: Option, ) -> Result<(CsrfToken, impl Reply), Rejection> { - let ctx = CommonContext::default() - .with_csrf_token(&csrf_token) - .maybe_with_session(session) - .finish() - .wrap_error()?; + let ctx = ().with_csrf(&csrf_token); // TODO: check if there is an existing session - let content = templates.render("login.html", &ctx).wrap_error()?; + let content = templates.render_login(&ctx)?; Ok(( csrf_token, with_header(content, "Content-Type", "text/html"), diff --git a/matrix-authentication-service/src/handlers/views/reauth.rs b/matrix-authentication-service/src/handlers/views/reauth.rs index 0f2921989..9021ea239 100644 --- a/matrix-authentication-service/src/handlers/views/reauth.rs +++ b/matrix-authentication-service/src/handlers/views/reauth.rs @@ -27,7 +27,7 @@ use crate::{ with_pool, with_templates, CsrfToken, }, storage::SessionInfo, - templates::{CommonContext, Templates}, + templates::{TemplateContext, Templates}, }; #[derive(Deserialize, Debug)] @@ -63,13 +63,9 @@ async fn get( csrf_token: CsrfToken, session: SessionInfo, ) -> Result<(CsrfToken, impl Reply), Rejection> { - let ctx = CommonContext::default() - .with_csrf_token(&csrf_token) - .with_session(session) - .finish() - .wrap_error()?; + let ctx = ().with_session(session).with_csrf(&csrf_token); - let content = templates.render("reauth.html", &ctx).wrap_error()?; + let content = templates.render_reauth(&ctx)?; Ok(( csrf_token, with_header(content, "Content-Type", "text/html"), diff --git a/matrix-authentication-service/src/templates.rs b/matrix-authentication-service/src/templates.rs index cc4667b0d..00e1ff7e7 100644 --- a/matrix-authentication-service/src/templates.rs +++ b/matrix-authentication-service/src/templates.rs @@ -12,62 +12,152 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{ops::Deref, sync::Arc}; +use std::{collections::HashSet, string::ToString, sync::Arc}; -use anyhow::Context as _; use serde::Serialize; -use tera::{Context, Tera}; -use tracing::info; +use tera::{Context, Error as TeraError, Tera}; +use thiserror::Error; +use tracing::{debug, info}; +use warp::reject::Reject; use crate::{filters::CsrfToken, storage::SessionInfo}; #[derive(Clone)] pub struct Templates(Arc); +#[derive(Error, Debug)] +pub enum TemplateLoadingError { + #[error("could not load and compile some templates")] + Compile(#[from] TeraError), + + #[error("missing templates {missing:?}")] + MissingTemplates { + missing: HashSet, + loaded: HashSet, + }, +} + impl Templates { - pub fn load() -> Result { + pub fn load() -> Result { let path = format!("{}/templates/**/*.{{html,txt}}", env!("CARGO_MANIFEST_DIR")); info!(%path, "Loading templates"); let tera = Tera::new(&path)?; - Ok(Self(Arc::new(tera))) - } -} -impl Deref for Templates { - type Target = Tera; + let loaded: HashSet<_> = tera.get_template_names().collect(); + let needed: HashSet<_> = std::array::IntoIter::new(TEMPLATES).collect(); + debug!(?loaded, ?needed, "Templates loaded"); + let missing: HashSet<_> = needed.difference(&loaded).collect(); - fn deref(&self) -> &Self::Target { - self.0.as_ref() - } -} - -#[derive(Serialize, Default)] -pub struct CommonContext { - csrf_token: Option, - current_session: Option, -} - -impl CommonContext { - pub fn with_csrf_token(self, token: &CsrfToken) -> Self { - Self { - csrf_token: Some(token.form_value()), - ..self + if missing.is_empty() { + Ok(Self(Arc::new(tera))) + } else { + let missing = missing.into_iter().map(ToString::to_string).collect(); + let loaded = loaded.into_iter().map(ToString::to_string).collect(); + Err(TemplateLoadingError::MissingTemplates { missing, loaded }) } } +} - pub fn maybe_with_session(self, current_session: Option) -> Self { - Self { +#[derive(Error, Debug)] +pub enum TemplateError { + #[error("could not prepare context for template {template:?}")] + Context { + template: &'static str, + #[source] + source: TeraError, + }, + + #[error("could not render template {template:?}")] + Render { + template: &'static str, + #[source] + source: TeraError, + }, +} + +impl Reject for TemplateError {} + +macro_rules! count { + () => (0_usize); + ( $x:tt $($xs:tt)* ) => (1_usize + count!($($xs)*)); +} + +macro_rules! register_templates { + ( $($(#[doc = $doc:expr])* $name:ident ($param:ty) => $template:expr),* $(,)? ) => { + /// List of registered templates + static TEMPLATES: [&'static str; count!($($template)*)] = [$($template),*]; + + impl Templates { + $( + $(#[doc = $doc])? + pub fn $name(&self, context: &$param) -> Result { + let ctx = Context::from_serialize(context) + .map_err(|source| TemplateError::Context { template: $template, source })?; + + self.0.render($template, &ctx) + .map_err(|source| TemplateError::Render { template: $template, source }) + } + )* + } + }; +} + +register_templates!( + /// Render the login page + render_login(WithCsrf<()>) => "login.html", + + /// Render the home page + render_index(WithCsrf>) => "index.html", + + /// Render the re-authentication form + render_reauth(WithCsrf>) => "reauth.html", +); + +pub trait TemplateContext: Sized { + fn with_session(self, current_session: SessionInfo) -> WithSession { + WithSession { current_session, - ..self + inner: self, } } - #[allow(dead_code)] - pub fn with_session(self, current_session: SessionInfo) -> Self { - self.maybe_with_session(Some(current_session)) + fn maybe_with_session(self, current_session: Option) -> WithOptionalSession { + WithOptionalSession { + current_session, + inner: self, + } } - pub fn finish(self) -> anyhow::Result { - Context::from_serialize(&self).context("could not serialize common context for templates") + fn with_csrf(self, token: &CsrfToken) -> WithCsrf { + WithCsrf { + csrf_token: token.form_value(), + inner: self, + } } } + +impl TemplateContext for T {} + +#[derive(Serialize)] +pub struct WithCsrf { + csrf_token: String, + + #[serde(flatten)] + inner: T, +} + +#[derive(Serialize)] +pub struct WithSession { + current_session: SessionInfo, + + #[serde(flatten)] + inner: T, +} + +#[derive(Serialize)] +pub struct WithOptionalSession { + current_session: Option, + + #[serde(flatten)] + inner: T, +}