refactor: add renderer middleware

This commit is contained in:
Matthieu Bessat 2024-11-08 23:38:54 +01:00
parent 40b892391a
commit b6dcb7521e
16 changed files with 172 additions and 92 deletions

View file

@ -1,3 +1,5 @@
# OAuth2 spec # OAuth2 spec
https://datatracker.ietf.org/doc/html/rfc6749 https://datatracker.ietf.org/doc/html/rfc6749
https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template

View file

@ -1,22 +1,20 @@
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension}; use axum::{extract::State, response::IntoResponse, Extension};
use minijinja::context; use minijinja::context;
use crate::{server::AppState, services::session::TokenClaims}; use crate::{renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
pub async fn authorize_form( pub async fn authorize_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>,
Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse { ) -> impl IntoResponse {
// 1. Check if the app is already authorized // 1. Check if the app is already authorized
// 2. Query the app details // 2. Query the app details
renderer
Html( .render(
app_state.templating_env.get_template("pages/authorize.html").unwrap() "pages/authorize",
.render(context!()) context!()
.unwrap()
) )
} }

View file

@ -1,17 +1,16 @@
use axum::{extract::State, response::{Html, IntoResponse}}; use axum::{response::IntoResponse, Extension};
use axum_macros::debug_handler; use axum_macros::debug_handler;
use minijinja::context; use minijinja::context;
use crate::server::AppState; use crate::renderer::TemplateRenderer;
#[debug_handler] #[debug_handler]
pub async fn home( pub async fn home(
State(app_state): State<AppState> Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse { ) -> impl IntoResponse {
Html( renderer.render(
app_state.templating_env.get_template("pages/home.html").unwrap() "pages/home",
.render(context!()) context!()
.unwrap()
) )
} }

View file

@ -1,23 +1,20 @@
use chrono::{Duration, SecondsFormat, Utc}; use chrono::{Duration, SecondsFormat, Utc};
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form}; use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, Extension, Form};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use crate::{ use crate::{
models::user::{User, UserStatus}, models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
server::AppState,
services::{password::verify_password_hash, session::create_token}
}; };
pub async fn login_form( pub async fn login_form(
State(app_state): State<AppState> Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse { ) -> impl IntoResponse {
Html( renderer.render(
app_state.templating_env.get_template("pages/login.html").unwrap() "pages/login",
.render(context!()) context!()
.unwrap()
) )
} }
@ -33,6 +30,7 @@ const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7k
pub async fn perform_login( pub async fn perform_login(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Form(login): Form<LoginForm> Form(login): Form<LoginForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
// get user from db // get user from db
@ -50,29 +48,25 @@ pub async fn perform_login(
Err(_e) => DUMMY_PASSWORD_HASH.into() Err(_e) => DUMMY_PASSWORD_HASH.into()
}; };
let templ = app_state.templating_env.get_template("pages/login.html").unwrap();
if verify_password_hash(password_hash, login.password).is_err() { if verify_password_hash(password_hash, login.password).is_err() {
return ( return renderer.render_with_status(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Html( "pages/login",
templ.render(context!( context!(
error => Some("Invalid login or password.".to_string()) error => Some("Invalid login or password.".to_string())
)).unwrap()
) )
).into_response(); );
} }
let user = user_res.expect("Expected User to be found."); let user = user_res.expect("Expected User to be found.");
if user.status == UserStatus::Disabled { if user.status == UserStatus::Disabled {
return ( return renderer.render_with_status(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Html( "pages/login",
templ.render(context!( context!(
error => Some("This account is disabled.".to_string()) error => Some("This account is disabled.".to_string())
)).unwrap()
) )
).into_response(); );
} }
info!("User {:?} {:?} logged in", &user.handle, &user.email); info!("User {:?} {:?} logged in", &user.handle, &user.email);
@ -87,13 +81,16 @@ pub async fn perform_login(
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting // TODO: handle keep_session boolean from form and specify cookie max age only if this setting
// is true // is true
let cookie_max_age = Duration::days(7).num_seconds(); let cookie_max_age = Duration::days(7).num_seconds();
// enforce SameSite=Lax to avoid CSRF
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}"); let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap()); headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap()); headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
(StatusCode::FOUND, headers, Html( renderer.render_with_status(
templ.render(context!()).unwrap() StatusCode::FOUND,
)).into_response() "pages/login",
context!()
)
} }

View file

@ -1,13 +1,13 @@
use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use base64::prelude::{Engine, BASE64_STANDARD};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use crate::{models::user::User, server::AppState, services::session::TokenClaims}; use crate::{models::user::User, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
pub async fn me_page( pub async fn me_page(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -17,19 +17,18 @@ pub async fn me_page(
.await .await
.expect("To get user from claim"); .expect("To get user from claim");
Html( renderer.render(
app_state.templating_env.get_template("pages/me/index.html").unwrap() "pages/me/index",
.render(context!( context!(
user => user_res, user => user_res
user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x)) )
))
.unwrap()
) )
} }
pub async fn me_update_details_form( pub async fn me_update_details_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -39,12 +38,11 @@ pub async fn me_update_details_form(
.await .await
.expect("To get user from claim"); .expect("To get user from claim");
Html( renderer.render(
app_state.templating_env.get_template("pages/me/details-form.html").unwrap() "pages/me/details-form",
.render(context!( context!(
user => user_res user => user_res
)) )
.unwrap()
) )
} }
@ -64,10 +62,11 @@ struct UserDetailsUpdateForm {
pub async fn me_perform_update_details( pub async fn me_perform_update_details(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims>, Extension(token_claims): Extension<TokenClaims>,
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm> TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap(); let template_path = "pages/me/details-form";
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1") let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
.bind(&token_claims.sub) .bind(&token_claims.sub)
@ -79,8 +78,6 @@ pub async fn me_perform_update_details(
.execute(&app_state.db) .execute(&app_state.db)
.await; .await;
dbg!(&update_res);
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub) .bind(&token_claims.sub)
.fetch_one(&app_state.db) .fetch_one(&app_state.db)
@ -89,28 +86,23 @@ pub async fn me_perform_update_details(
match update_res { match update_res {
Ok(_) => { Ok(_) => {
( renderer.render(
StatusCode::OK, template_path,
Html( context!(
template.render(context!(
success => true, success => true,
user => user_res user => user_res
))
.unwrap()
) )
).into_response() )
}, },
Err(err) => { Err(err) => {
dbg!(&err); dbg!(&err);
( renderer.render(
StatusCode::BAD_REQUEST, template_path,
Html( context!(
template.render(context!(
error => Some("Cannot update user details".to_string()), error => Some("Cannot update user details".to_string()),
user => user_res user => user_res
)).unwrap()
) )
).into_response() )
} }
} }

View file

@ -7,6 +7,7 @@ pub mod cli;
pub mod utils; pub mod utils;
pub mod services; pub mod services;
pub mod middlewares; pub mod middlewares;
pub mod renderer;
use std::{env, fs}; use std::{env, fs};
use anyhow::{Result, Context, anyhow}; use anyhow::{Result, Context, anyhow};

View file

@ -1 +1,2 @@
pub mod auth; pub mod auth;
pub mod renderer;

View file

@ -0,0 +1,17 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension};
use crate::{renderer::TemplateRenderer, server::AppState, services::session::{verify_token, TokenClaims}};
pub async fn renderer_middleware(
State(app_state): State<AppState>,
token_claims_ext: Option<Extension<TokenClaims>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let renderer_instance = TemplateRenderer {
env: app_state.templating_env,
token_claims: token_claims_ext.map(|x| x.0)
};
req.extensions_mut().insert(renderer_instance);
Ok(next.run(req).await)
}

View file

@ -27,4 +27,3 @@ struct User {
last_login_at: Option<DateTime<Utc>>, last_login_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc> created_at: DateTime<Utc>
} }

60
src/renderer.rs Normal file
View file

@ -0,0 +1,60 @@
use axum::{extract::FromRef, http::{Response, StatusCode}, response::{Html, IntoResponse}, Extension};
use fully_pub::fully_pub;
use log::error;
use minijinja::{context, Environment, Value};
use crate::{server::AppState, services::session::TokenClaims};
#[derive(Clone)]
#[fully_pub]
struct TemplateRenderer {
env: Environment<'static>,
token_claims: Option<TokenClaims>
}
impl TemplateRenderer {
pub(crate) fn from_env(
env: Environment<'static>,
) -> Self {
Self {
env,
token_claims: None
}
}
pub(crate) fn from_token_claims(
app_state: AppState,
token_claims: TokenClaims
) -> Self {
Self {
env: app_state.templating_env,
token_claims: Some(token_claims)
}
}
pub(crate) fn render(&self, name: &str, ctx: Value) -> impl IntoResponse {
match self
.env
.get_template(&format!("{name}.html"))
.and_then(|tmpl| tmpl.render(context! { token_claims => self.token_claims, ..ctx }))
{
Ok(content) => Html(content).into_response(),
Err(err) => {
dbg!(err);
error!("FATAL: Failed to render template {}", name);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
}
pub(crate) fn render_with_status(&self, status: StatusCode, name: &str, ctx: Value) -> impl IntoResponse {
let mut res = self.render(name, ctx).into_response();
if res.status().is_server_error() {
return res
}
*res.status_mut() = status;
return res;
}
}

View file

@ -1,7 +1,7 @@
use axum::{middleware, routing::{get, post}, Router}; use axum::{middleware, routing::{get, post}, Router};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}}; use crate::{controllers::ui, middlewares::{auth::auth_middleware, renderer::renderer_middleware}, server::{AppState, ServerConfig}};
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> { pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
let public_routes = Router::new() let public_routes = Router::new()
@ -17,11 +17,12 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
.route("/me/details-form", post(ui::me::me_perform_update_details)) .route("/me/details-form", post(ui::me::me_perform_update_details))
.route("/logout", get(ui::logout::perform_logout)) .route("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form)) .route("/authorize", get(ui::authorize::authorize_form))
.layer(middleware::from_fn_with_state(app_state, auth_middleware)); .layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware));
Router::new() Router::new()
.merge(public_routes) .merge(public_routes)
.merge(user_routes) .merge(user_routes)
.layer(middleware::from_fn_with_state(app_state, renderer_middleware))
.nest_service( .nest_service(
"/assets", "/assets",
ServeDir::new(server_config.assets_path.clone()) ServeDir::new(server_config.assets_path.clone())

View file

@ -1,3 +1,4 @@
use base64::{prelude::BASE64_STANDARD, Engine};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use anyhow::{Result, Context}; use anyhow::{Result, Context};
use log::info; use log::info;
@ -13,6 +14,9 @@ fn build_templating_env(config: &Config) -> Environment<'static> {
env.add_global("gl", context! { env.add_global("gl", context! {
instance => config.instance instance => config.instance
}); });
env.add_function("encode_b64str", |bin_val: Vec<u8>| {
BASE64_STANDARD.encode(bin_val)
});
env env
} }

View file

@ -5,23 +5,26 @@
<div class="collapse navbar-collapse"> <div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a> <a class="nav-link active" aria-current="page" href="/">Home</a>
</li> </li>
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if token_claims is not defined %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/login">Login</a> <a class="nav-link" href="/login">Login</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/register">Register</a> <a class="nav-link" href="/register">Register</a>
</li> </li>
{% endif %}
{% if token_claims is defined %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/me">Me</a> <a class="nav-link" href="/me">Me</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/logout">Logout</a> <a class="nav-link" href="/logout">Logout</a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -7,8 +7,14 @@
</div> </div>
{% endif %} {% endif %}
<form id="authorize-form" method="post"> <form id="authorize-form" method="post">
<input id="keep_session" type="checkbox" class="form-check-input"> <h1>Do you authorize this app?</h1>
<label class="form-check-label" for="keep_session">Check me out</label> <ul>
<li>App name: </li>
<li>Permisions: read basics</li>
</ul>
<input type="hidden" name="client_id" value="" />
<input type="hidden" name="scope" value="" />
<input type="hidden" name="state" value="" />
<button type="submit" class="btn btn-primary">Authorize</button> <button type="submit" class="btn btn-primary">Authorize</button>
</form> </form>

View file

@ -5,8 +5,8 @@
<a href="/me/details-form">Update details</a> <a href="/me/details-form">Update details</a>
<p> <p>
{% if user_picture %} {% if user.picture %}
<img src="data:image/*;base64,{{ user_picture }}" style="width: 150px; height: 150px; object-fit: contain"> <img src="data:image/*;base64,{{ encode_b64str(user.picture) }}" style="width: 150px; height: 150px; object-fit: contain">
{% endif %} {% endif %}
<ul> <ul>
<li> <li>

View file

@ -24,7 +24,7 @@
class="form-control" class="form-control"
/> />
</div> </div>
<div> <div class="mb-3">
<label for="email">Email</label> <label for="email">Email</label>
<input <input
id="email" name="email" type="email" id="email" name="email" type="email"
@ -32,7 +32,7 @@
class="form-control" class="form-control"
/> />
</div> </div>
<div> <div class="mb-3">
<label for="password">Password</label> <label for="password">Password</label>
<input <input
id="password" name="password" type="password" id="password" name="password" type="password"